@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
package/src/float.ts ADDED
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Floating bubble embed - floating button that opens a chat window
3
+ * SSR-safe - returns no-op handle on server
4
+ */
5
+
6
+ import type { EmbedConfig, FloatHandle, ThemeConfig } 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, MIC_ICON, CLOSE_ICON } from "./styles";
16
+ import { cn, getThemeClass, resolveIsDark } from "./utils";
17
+
18
+ type FloatConfig = EmbedConfig & { _themeConfig?: ThemeConfig };
19
+
20
+ function createNoOpHandle(researchId: string): FloatHandle {
21
+ return {
22
+ unmount: () => {},
23
+ update: () => {},
24
+ destroy: () => {},
25
+ open: () => {},
26
+ close: () => {},
27
+ toggle: () => {},
28
+ isOpen: false,
29
+ researchId,
30
+ type: "float",
31
+ iframe: null,
32
+ container: null,
33
+ };
34
+ }
35
+
36
+ export function createFloatBubble(config: FloatConfig): FloatHandle {
37
+ const { researchId, _themeConfig, theme, brand } = config;
38
+
39
+ // SSR safety: return no-op handle
40
+ if (!hasDom()) {
41
+ return createNoOpHandle(researchId);
42
+ }
43
+ const host = getHost(config.host);
44
+
45
+ injectStyles();
46
+ ensureGlobalListeners();
47
+
48
+ // Create bubble button
49
+ const bubble = document.createElement("button");
50
+ bubble.className = cn(
51
+ "perspective-float-bubble perspective-embed-root",
52
+ getThemeClass(config.theme)
53
+ );
54
+ bubble.innerHTML = MIC_ICON;
55
+ bubble.setAttribute("aria-label", "Open chat");
56
+ bubble.setAttribute("data-perspective", "float-bubble");
57
+
58
+ // Apply theme color if available
59
+ if (_themeConfig || brand) {
60
+ const isDark = resolveIsDark(theme);
61
+ const bg = isDark
62
+ ? (brand?.dark?.primary ?? _themeConfig?.darkPrimaryColor ?? "#a78bfa")
63
+ : (brand?.light?.primary ?? _themeConfig?.primaryColor ?? "#7c3aed");
64
+ bubble.style.setProperty("--perspective-float-bg", bg);
65
+ bubble.style.setProperty(
66
+ "--perspective-float-shadow",
67
+ `0 4px 12px ${bg}66`
68
+ );
69
+ bubble.style.setProperty(
70
+ "--perspective-float-shadow-hover",
71
+ `0 6px 16px ${bg}80`
72
+ );
73
+ bubble.style.backgroundColor = bg;
74
+ bubble.style.boxShadow = `0 4px 12px ${bg}66`;
75
+ }
76
+
77
+ document.body.appendChild(bubble);
78
+
79
+ let floatWindow: HTMLElement | null = null;
80
+ let iframe: HTMLIFrameElement | null = null;
81
+ let cleanup: (() => void) | null = null;
82
+ let unregisterIframe: (() => void) | null = null;
83
+ let isOpen = false;
84
+
85
+ // Mutable config reference for updates
86
+ let currentConfig = { ...config };
87
+
88
+ const openFloat = () => {
89
+ if (isOpen) return;
90
+ isOpen = true;
91
+
92
+ // Create float window
93
+ floatWindow = document.createElement("div");
94
+ floatWindow.className = cn(
95
+ "perspective-float-window perspective-embed-root",
96
+ getThemeClass(currentConfig.theme)
97
+ );
98
+
99
+ // Create close button
100
+ const closeBtn = document.createElement("button");
101
+ closeBtn.className = "perspective-close";
102
+ closeBtn.innerHTML = CLOSE_ICON;
103
+ closeBtn.setAttribute("aria-label", "Close chat");
104
+ closeBtn.addEventListener("click", closeFloat);
105
+
106
+ // Create loading indicator with theme and brand colors
107
+ const loading = createLoadingIndicator({
108
+ theme: currentConfig.theme,
109
+ brand: currentConfig.brand,
110
+ });
111
+ loading.style.borderRadius = "16px";
112
+
113
+ // Create iframe (hidden initially)
114
+ iframe = createIframe(
115
+ researchId,
116
+ "float",
117
+ host,
118
+ currentConfig.params,
119
+ currentConfig.brand,
120
+ currentConfig.theme
121
+ );
122
+ iframe.style.opacity = "0";
123
+ iframe.style.transition = "opacity 0.3s ease";
124
+
125
+ floatWindow.appendChild(closeBtn);
126
+ floatWindow.appendChild(loading);
127
+ floatWindow.appendChild(iframe);
128
+ document.body.appendChild(floatWindow);
129
+
130
+ // Set up message listener with loading state handling
131
+ cleanup = setupMessageListener(
132
+ researchId,
133
+ {
134
+ get onReady() {
135
+ return () => {
136
+ loading.style.opacity = "0";
137
+ iframe!.style.opacity = "1";
138
+ setTimeout(() => loading.remove(), 300);
139
+ currentConfig.onReady?.();
140
+ };
141
+ },
142
+ get onSubmit() {
143
+ return currentConfig.onSubmit;
144
+ },
145
+ get onNavigate() {
146
+ return currentConfig.onNavigate;
147
+ },
148
+ get onClose() {
149
+ return closeFloat;
150
+ },
151
+ get onError() {
152
+ return currentConfig.onError;
153
+ },
154
+ },
155
+ iframe,
156
+ host,
157
+ { skipResize: true }
158
+ );
159
+
160
+ // Register iframe for theme change notifications
161
+ if (iframe) {
162
+ unregisterIframe = registerIframe(iframe, host);
163
+ }
164
+
165
+ // Update bubble icon to close
166
+ bubble.innerHTML = CLOSE_ICON;
167
+ bubble.setAttribute("aria-label", "Close chat");
168
+ };
169
+
170
+ const closeFloat = () => {
171
+ if (!isOpen) return;
172
+ isOpen = false;
173
+
174
+ cleanup?.();
175
+ unregisterIframe?.();
176
+ floatWindow?.remove();
177
+ floatWindow = null;
178
+ iframe = null;
179
+ cleanup = null;
180
+ unregisterIframe = null;
181
+
182
+ // Restore bubble icon
183
+ bubble.innerHTML = MIC_ICON;
184
+ bubble.setAttribute("aria-label", "Open chat");
185
+
186
+ currentConfig.onClose?.();
187
+ };
188
+
189
+ // Toggle on bubble click
190
+ bubble.addEventListener("click", () => {
191
+ if (isOpen) {
192
+ closeFloat();
193
+ } else {
194
+ openFloat();
195
+ }
196
+ });
197
+
198
+ const unmount = () => {
199
+ closeFloat();
200
+ bubble.remove();
201
+ };
202
+
203
+ return {
204
+ unmount,
205
+ update: (options: Parameters<FloatHandle["update"]>[0]) => {
206
+ currentConfig = { ...currentConfig, ...options };
207
+ },
208
+ destroy: unmount,
209
+ open: openFloat,
210
+ close: closeFloat,
211
+ toggle: () => {
212
+ if (isOpen) {
213
+ closeFloat();
214
+ } else {
215
+ openFloat();
216
+ }
217
+ },
218
+ get isOpen() {
219
+ return isOpen;
220
+ },
221
+ researchId,
222
+ type: "float" as const,
223
+ get iframe() {
224
+ return iframe;
225
+ },
226
+ container: bubble,
227
+ };
228
+ }
229
+
230
+ /** @deprecated Use createFloatBubble instead */
231
+ export const createChatBubble = createFloatBubble;
@@ -0,0 +1,224 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { createFullpage } from "./fullpage";
3
+ import * as config from "./config";
4
+
5
+ describe("createFullpage", () => {
6
+ beforeEach(() => {
7
+ document.body.innerHTML = "";
8
+ });
9
+
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ it("creates fullpage container", () => {
16
+ const handle = createFullpage({
17
+ researchId: "test-research-id",
18
+ });
19
+
20
+ expect(handle.researchId).toBe("test-research-id");
21
+ expect(handle.type).toBe("fullpage");
22
+ expect(handle.iframe).toBeInstanceOf(HTMLIFrameElement);
23
+ expect(document.querySelector(".perspective-fullpage")).toBeTruthy();
24
+
25
+ handle.unmount();
26
+ });
27
+
28
+ it("creates loading indicator", () => {
29
+ const handle = createFullpage({
30
+ researchId: "test-research-id",
31
+ });
32
+
33
+ const loading = document.querySelector(".perspective-loading");
34
+ expect(loading).toBeTruthy();
35
+
36
+ handle.unmount();
37
+ });
38
+
39
+ it("returns no-op handle when no DOM", () => {
40
+ vi.spyOn(config, "hasDom").mockReturnValue(false);
41
+
42
+ const handle = createFullpage({
43
+ researchId: "test-research-id",
44
+ });
45
+
46
+ expect(handle.iframe).toBeNull();
47
+ expect(handle.container).toBeNull();
48
+ expect(handle.unmount).toBeInstanceOf(Function);
49
+ expect(() => handle.unmount()).not.toThrow();
50
+ });
51
+
52
+ it("unmount removes fullpage container", () => {
53
+ const handle = createFullpage({
54
+ researchId: "test-research-id",
55
+ });
56
+
57
+ expect(document.querySelector(".perspective-fullpage")).toBeTruthy();
58
+
59
+ handle.unmount();
60
+
61
+ expect(document.querySelector(".perspective-fullpage")).toBeFalsy();
62
+ });
63
+
64
+ it("destroy is alias for unmount", () => {
65
+ const handle = createFullpage({
66
+ researchId: "test-research-id",
67
+ });
68
+
69
+ expect(document.querySelector(".perspective-fullpage")).toBeTruthy();
70
+
71
+ handle.destroy();
72
+
73
+ expect(document.querySelector(".perspective-fullpage")).toBeFalsy();
74
+ });
75
+
76
+ it("calls onClose on unmount", () => {
77
+ const onClose = vi.fn();
78
+ const handle = createFullpage({
79
+ researchId: "test-research-id",
80
+ onClose,
81
+ });
82
+
83
+ handle.unmount();
84
+
85
+ expect(onClose).toHaveBeenCalled();
86
+ });
87
+
88
+ it("applies theme class", () => {
89
+ const handle = createFullpage({
90
+ researchId: "test-research-id",
91
+ theme: "dark",
92
+ });
93
+
94
+ const container = document.querySelector(".perspective-fullpage");
95
+ expect(container?.classList.contains("perspective-dark-theme")).toBe(true);
96
+
97
+ handle.unmount();
98
+ });
99
+
100
+ it("uses custom host", () => {
101
+ const handle = createFullpage({
102
+ researchId: "test-research-id",
103
+ host: "https://custom.example.com",
104
+ });
105
+
106
+ const iframe = handle.iframe as HTMLIFrameElement;
107
+ expect(iframe.src).toContain("https://custom.example.com");
108
+
109
+ handle.unmount();
110
+ });
111
+
112
+ it("passes params to iframe", () => {
113
+ const handle = createFullpage({
114
+ researchId: "test-research-id",
115
+ params: { source: "test", campaign: "demo" },
116
+ });
117
+
118
+ const iframe = handle.iframe as HTMLIFrameElement;
119
+ const url = new URL(iframe.src);
120
+ expect(url.searchParams.get("source")).toBe("test");
121
+ expect(url.searchParams.get("campaign")).toBe("demo");
122
+
123
+ handle.unmount();
124
+ });
125
+
126
+ it("update modifies config", () => {
127
+ const onSubmit1 = vi.fn();
128
+ const onSubmit2 = vi.fn();
129
+
130
+ const handle = createFullpage({
131
+ researchId: "test-research-id",
132
+ onSubmit: onSubmit1,
133
+ });
134
+
135
+ expect(() => handle.update({ onSubmit: onSubmit2 })).not.toThrow();
136
+
137
+ handle.unmount();
138
+ });
139
+
140
+ describe("update() behavior", () => {
141
+ const host = "https://getperspective.ai";
142
+ const researchId = "test-research-id";
143
+
144
+ const sendMessage = (
145
+ iframe: HTMLIFrameElement,
146
+ type: string,
147
+ extra?: Record<string, unknown>
148
+ ) => {
149
+ window.dispatchEvent(
150
+ new MessageEvent("message", {
151
+ data: { type, researchId, ...extra },
152
+ origin: host,
153
+ source: iframe.contentWindow,
154
+ })
155
+ );
156
+ };
157
+
158
+ it("update changes which callback is invoked", () => {
159
+ const onSubmit1 = vi.fn();
160
+ const onSubmit2 = vi.fn();
161
+
162
+ const handle = createFullpage({
163
+ researchId,
164
+ host,
165
+ onSubmit: onSubmit1,
166
+ });
167
+
168
+ handle.update({ onSubmit: onSubmit2 });
169
+
170
+ sendMessage(handle.iframe!, "perspective:submit");
171
+
172
+ expect(onSubmit2).toHaveBeenCalledTimes(1);
173
+ expect(onSubmit1).not.toHaveBeenCalled();
174
+
175
+ handle.unmount();
176
+ });
177
+
178
+ it("sequential updates only use latest callback", () => {
179
+ const fn1 = vi.fn();
180
+ const fn2 = vi.fn();
181
+ const fn3 = vi.fn();
182
+
183
+ const handle = createFullpage({
184
+ researchId,
185
+ host,
186
+ onSubmit: fn1,
187
+ });
188
+
189
+ handle.update({ onSubmit: fn2 });
190
+ handle.update({ onSubmit: fn3 });
191
+
192
+ sendMessage(handle.iframe!, "perspective:submit");
193
+
194
+ expect(fn3).toHaveBeenCalledTimes(1);
195
+ expect(fn2).not.toHaveBeenCalled();
196
+ expect(fn1).not.toHaveBeenCalled();
197
+
198
+ handle.unmount();
199
+ });
200
+
201
+ it("destroy prevents further callback invocations", () => {
202
+ const onSubmit = vi.fn();
203
+ const onClose = vi.fn();
204
+
205
+ const handle = createFullpage({
206
+ researchId,
207
+ host,
208
+ onSubmit,
209
+ onClose,
210
+ });
211
+
212
+ const iframe = handle.iframe!;
213
+
214
+ handle.unmount();
215
+ expect(onClose).toHaveBeenCalledTimes(1);
216
+
217
+ sendMessage(iframe, "perspective:submit");
218
+ sendMessage(iframe, "perspective:close");
219
+
220
+ expect(onSubmit).not.toHaveBeenCalled();
221
+ expect(onClose).toHaveBeenCalledTimes(1);
222
+ });
223
+ });
224
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Fullpage embed - takes over entire viewport
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 } 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: "fullpage" as const,
25
+ iframe: null,
26
+ container: null,
27
+ };
28
+ }
29
+
30
+ export function createFullpage(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 fullpage container
43
+ const container = document.createElement("div");
44
+ container.className = cn(
45
+ "perspective-embed-root perspective-fullpage",
46
+ getThemeClass(config.theme)
47
+ );
48
+
49
+ // Create loading indicator with theme and brand colors
50
+ const loading = createLoadingIndicator({
51
+ theme: config.theme,
52
+ brand: config.brand,
53
+ });
54
+ container.appendChild(loading);
55
+
56
+ // Create iframe (hidden initially)
57
+ const iframe = createIframe(
58
+ researchId,
59
+ "fullpage",
60
+ host,
61
+ config.params,
62
+ config.brand,
63
+ config.theme
64
+ );
65
+ iframe.style.opacity = "0";
66
+ iframe.style.transition = "opacity 0.3s ease";
67
+
68
+ container.appendChild(iframe);
69
+ document.body.appendChild(container);
70
+
71
+ // Mutable config reference for updates
72
+ let currentConfig = { ...config };
73
+ let messageCleanup: (() => void) | null = null;
74
+
75
+ // Register iframe for theme change notifications
76
+ const unregisterIframe = registerIframe(iframe, host);
77
+
78
+ const unmount = () => {
79
+ messageCleanup?.();
80
+ unregisterIframe();
81
+ container.remove();
82
+ currentConfig.onClose?.();
83
+ };
84
+
85
+ // Set up message listener
86
+ messageCleanup = setupMessageListener(
87
+ researchId,
88
+ {
89
+ get onReady() {
90
+ return () => {
91
+ loading.style.opacity = "0";
92
+ iframe.style.opacity = "1";
93
+ setTimeout(() => loading.remove(), 300);
94
+ currentConfig.onReady?.();
95
+ };
96
+ },
97
+ get onSubmit() {
98
+ return currentConfig.onSubmit;
99
+ },
100
+ get onNavigate() {
101
+ return currentConfig.onNavigate;
102
+ },
103
+ get onClose() {
104
+ return unmount;
105
+ },
106
+ get onError() {
107
+ return currentConfig.onError;
108
+ },
109
+ },
110
+ iframe,
111
+ host,
112
+ { skipResize: true }
113
+ );
114
+
115
+ return {
116
+ unmount,
117
+ update: (options) => {
118
+ currentConfig = { ...currentConfig, ...options };
119
+ },
120
+ destroy: unmount,
121
+ researchId,
122
+ type: "fullpage" as const,
123
+ iframe,
124
+ container,
125
+ };
126
+ }