@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,214 @@
1
+ /**
2
+ * Shared constants for Perspective Embed SDK
3
+ * This file is SSR-safe - no DOM access at import time
4
+ * Used by both SDK bundle and the main Perspective app
5
+ */
6
+
7
+ // ============================================================================
8
+ // SDK Version & Features
9
+ // ============================================================================
10
+
11
+ /** SDK version for handshake protocol */
12
+ export const SDK_VERSION = "1.0.0";
13
+
14
+ /** Feature flags as bitset for version negotiation */
15
+ export const FEATURES = {
16
+ RESIZE: 1 << 0, // 0b0001
17
+ THEME_SYNC: 1 << 1, // 0b0010
18
+ ANON_ID: 1 << 2, // 0b0100
19
+ SCROLLBAR_STYLES: 1 << 3, // 0b1000
20
+ } as const;
21
+
22
+ /** Current SDK feature set */
23
+ export const CURRENT_FEATURES =
24
+ FEATURES.RESIZE |
25
+ FEATURES.THEME_SYNC |
26
+ FEATURES.ANON_ID |
27
+ FEATURES.SCROLLBAR_STYLES;
28
+
29
+ // ============================================================================
30
+ // URL Parameter Keys
31
+ // ============================================================================
32
+
33
+ export const PARAM_KEYS = {
34
+ // User identification
35
+ email: "email",
36
+ name: "name",
37
+
38
+ // Navigation
39
+ returnUrl: "returnUrl",
40
+
41
+ // Interview behavior
42
+ voice: "voice",
43
+ scroll: "scroll",
44
+ hideProgress: "hideProgress",
45
+ hideGreeting: "hideGreeting",
46
+ hideBranding: "hideBranding",
47
+
48
+ // Interview mode & auth
49
+ mode: "mode",
50
+ invite: "invite",
51
+
52
+ // System (internal)
53
+ embed: "embed",
54
+ embedType: "embed_type",
55
+ theme: "theme",
56
+ } as const;
57
+
58
+ export type ParamKey = (typeof PARAM_KEYS)[keyof typeof PARAM_KEYS];
59
+
60
+ // ============================================================================
61
+ // Brand Color Keys
62
+ // ============================================================================
63
+
64
+ export const BRAND_KEYS = {
65
+ // Light mode
66
+ primary: "brand.primary",
67
+ secondary: "brand.secondary",
68
+ bg: "brand.bg",
69
+ text: "brand.text",
70
+
71
+ // Dark mode
72
+ darkPrimary: "brand.dark.primary",
73
+ darkSecondary: "brand.dark.secondary",
74
+ darkBg: "brand.dark.bg",
75
+ darkText: "brand.dark.text",
76
+ } as const;
77
+
78
+ export type BrandKey = (typeof BRAND_KEYS)[keyof typeof BRAND_KEYS];
79
+
80
+ // ============================================================================
81
+ // UTM Parameters (auto-forwarded from parent URL)
82
+ // ============================================================================
83
+
84
+ export const UTM_PARAMS = [
85
+ "utm_source",
86
+ "utm_medium",
87
+ "utm_campaign",
88
+ "utm_term",
89
+ "utm_content",
90
+ ] as const;
91
+
92
+ export type UtmParam = (typeof UTM_PARAMS)[number];
93
+
94
+ // ============================================================================
95
+ // Reserved Parameters (cannot be overridden via custom params)
96
+ // ============================================================================
97
+
98
+ export const RESERVED_PARAMS: Set<string> = new Set([
99
+ PARAM_KEYS.embed,
100
+ PARAM_KEYS.embedType,
101
+ PARAM_KEYS.theme,
102
+ BRAND_KEYS.primary,
103
+ BRAND_KEYS.secondary,
104
+ BRAND_KEYS.bg,
105
+ BRAND_KEYS.text,
106
+ BRAND_KEYS.darkPrimary,
107
+ BRAND_KEYS.darkSecondary,
108
+ BRAND_KEYS.darkBg,
109
+ BRAND_KEYS.darkText,
110
+ ...UTM_PARAMS,
111
+ ]);
112
+
113
+ // ============================================================================
114
+ // Data Attributes (HTML declarative initialization)
115
+ // ============================================================================
116
+
117
+ export const DATA_ATTRS = {
118
+ widget: "data-perspective-widget",
119
+ popup: "data-perspective-popup",
120
+ slider: "data-perspective-slider",
121
+ float: "data-perspective-float", // Primary name
122
+ chat: "data-perspective-chat", // Legacy alias
123
+ fullpage: "data-perspective-fullpage",
124
+ params: "data-perspective-params",
125
+ brand: "data-perspective-brand",
126
+ brandDark: "data-perspective-brand-dark",
127
+ theme: "data-perspective-theme",
128
+ noStyle: "data-perspective-no-style",
129
+ } as const;
130
+
131
+ export type DataAttr = (typeof DATA_ATTRS)[keyof typeof DATA_ATTRS];
132
+
133
+ // ============================================================================
134
+ // PostMessage Event Types
135
+ // ============================================================================
136
+
137
+ export const MESSAGE_TYPES = {
138
+ // SDK -> Iframe (initialization)
139
+ init: "perspective:init",
140
+
141
+ // Iframe -> SDK
142
+ ready: "perspective:ready",
143
+ resize: "perspective:resize",
144
+ submit: "perspective:submit",
145
+ close: "perspective:close",
146
+ error: "perspective:error",
147
+ redirect: "perspective:redirect",
148
+
149
+ // SDK -> Iframe (internal)
150
+ anonId: "perspective:anon-id",
151
+ injectStyles: "perspective:inject-styles",
152
+ themeChange: "perspective:theme-change",
153
+
154
+ // Iframe -> SDK (internal)
155
+ requestScrollbarStyles: "perspective:request-scrollbar-styles",
156
+ } as const;
157
+
158
+ export type MessageType = (typeof MESSAGE_TYPES)[keyof typeof MESSAGE_TYPES];
159
+
160
+ // ============================================================================
161
+ // Error Codes
162
+ // ============================================================================
163
+
164
+ export const ERROR_CODES = {
165
+ SDK_OUTDATED: "SDK_OUTDATED",
166
+ INVALID_RESEARCH: "INVALID_RESEARCH",
167
+ UNKNOWN: "UNKNOWN",
168
+ } as const;
169
+
170
+ export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
171
+
172
+ // ============================================================================
173
+ // Param Values (for boolean-like string params)
174
+ // ============================================================================
175
+
176
+ export const PARAM_VALUES = {
177
+ disabled: "0",
178
+ enabled: "1",
179
+ true: "true",
180
+ false: "false",
181
+ } as const;
182
+
183
+ // ============================================================================
184
+ // Theme Values
185
+ // ============================================================================
186
+
187
+ export const THEME_VALUES = {
188
+ dark: "dark",
189
+ light: "light",
190
+ system: "system",
191
+ } as const;
192
+
193
+ export type ThemeValue = (typeof THEME_VALUES)[keyof typeof THEME_VALUES];
194
+
195
+ // ============================================================================
196
+ // Interview Mode Values (for mode param)
197
+ // ============================================================================
198
+
199
+ export const MODE_VALUES = {
200
+ preview: "preview",
201
+ restart: "restart",
202
+ normal: "normal",
203
+ simulated: "simulated",
204
+ } as const;
205
+
206
+ export type ModeValue = (typeof MODE_VALUES)[keyof typeof MODE_VALUES];
207
+
208
+ // ============================================================================
209
+ // localStorage Keys
210
+ // ============================================================================
211
+
212
+ export const STORAGE_KEYS = {
213
+ anonId: "perspective-anon-id",
214
+ } as const;
@@ -0,0 +1,332 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { createFloatBubble, createChatBubble } from "./float";
3
+ import * as config from "./config";
4
+
5
+ describe("createFloatBubble", () => {
6
+ beforeEach(() => {
7
+ document.body.innerHTML = "";
8
+ });
9
+
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ it("creates float bubble", () => {
16
+ const handle = createFloatBubble({
17
+ researchId: "test-research-id",
18
+ });
19
+
20
+ expect(handle.researchId).toBe("test-research-id");
21
+ expect(handle.type).toBe("float");
22
+ expect(handle.isOpen).toBe(false);
23
+ expect(document.querySelector(".perspective-float-bubble")).toBeTruthy();
24
+
25
+ handle.unmount();
26
+ });
27
+
28
+ it("returns no-op handle when no DOM", () => {
29
+ vi.spyOn(config, "hasDom").mockReturnValue(false);
30
+
31
+ const handle = createFloatBubble({
32
+ researchId: "test-research-id",
33
+ });
34
+
35
+ expect(handle.iframe).toBeNull();
36
+ expect(handle.container).toBeNull();
37
+ expect(handle.isOpen).toBe(false);
38
+ expect(handle.unmount).toBeInstanceOf(Function);
39
+ expect(() => handle.unmount()).not.toThrow();
40
+ expect(() => handle.open()).not.toThrow();
41
+ expect(() => handle.close()).not.toThrow();
42
+ expect(() => handle.toggle()).not.toThrow();
43
+ });
44
+
45
+ it("open() opens float window", () => {
46
+ const handle = createFloatBubble({
47
+ researchId: "test-research-id",
48
+ });
49
+
50
+ expect(handle.isOpen).toBe(false);
51
+ expect(document.querySelector(".perspective-float-window")).toBeFalsy();
52
+
53
+ handle.open();
54
+
55
+ expect(handle.isOpen).toBe(true);
56
+ expect(document.querySelector(".perspective-float-window")).toBeTruthy();
57
+
58
+ handle.unmount();
59
+ });
60
+
61
+ it("close() closes float window", () => {
62
+ const onClose = vi.fn();
63
+ const handle = createFloatBubble({
64
+ researchId: "test-research-id",
65
+ onClose,
66
+ });
67
+
68
+ handle.open();
69
+ expect(handle.isOpen).toBe(true);
70
+ expect(document.querySelector(".perspective-float-window")).toBeTruthy();
71
+
72
+ handle.close();
73
+
74
+ expect(handle.isOpen).toBe(false);
75
+ expect(document.querySelector(".perspective-float-window")).toBeFalsy();
76
+ expect(onClose).toHaveBeenCalled();
77
+ });
78
+
79
+ it("toggle() toggles float window", () => {
80
+ const handle = createFloatBubble({
81
+ researchId: "test-research-id",
82
+ });
83
+
84
+ expect(handle.isOpen).toBe(false);
85
+
86
+ handle.toggle();
87
+ expect(handle.isOpen).toBe(true);
88
+ expect(document.querySelector(".perspective-float-window")).toBeTruthy();
89
+
90
+ handle.toggle();
91
+ expect(handle.isOpen).toBe(false);
92
+ expect(document.querySelector(".perspective-float-window")).toBeFalsy();
93
+
94
+ handle.unmount();
95
+ });
96
+
97
+ it("clicking bubble toggles float window", () => {
98
+ const handle = createFloatBubble({
99
+ researchId: "test-research-id",
100
+ });
101
+
102
+ const bubble = document.querySelector(
103
+ ".perspective-float-bubble"
104
+ ) as HTMLButtonElement;
105
+ expect(bubble).toBeTruthy();
106
+
107
+ bubble.click();
108
+ expect(handle.isOpen).toBe(true);
109
+ expect(document.querySelector(".perspective-float-window")).toBeTruthy();
110
+
111
+ bubble.click();
112
+ expect(handle.isOpen).toBe(false);
113
+ expect(document.querySelector(".perspective-float-window")).toBeFalsy();
114
+
115
+ handle.unmount();
116
+ });
117
+
118
+ it("close button in float window closes it", () => {
119
+ const onClose = vi.fn();
120
+ const handle = createFloatBubble({
121
+ researchId: "test-research-id",
122
+ onClose,
123
+ });
124
+
125
+ handle.open();
126
+
127
+ const closeBtn = document.querySelector(
128
+ ".perspective-float-window .perspective-close"
129
+ ) as HTMLButtonElement;
130
+ expect(closeBtn).toBeTruthy();
131
+
132
+ closeBtn.click();
133
+
134
+ expect(handle.isOpen).toBe(false);
135
+ expect(document.querySelector(".perspective-float-window")).toBeFalsy();
136
+ expect(onClose).toHaveBeenCalled();
137
+ });
138
+
139
+ it("unmount removes bubble and window", () => {
140
+ const handle = createFloatBubble({
141
+ researchId: "test-research-id",
142
+ });
143
+
144
+ handle.open();
145
+ expect(document.querySelector(".perspective-float-bubble")).toBeTruthy();
146
+ expect(document.querySelector(".perspective-float-window")).toBeTruthy();
147
+
148
+ handle.unmount();
149
+
150
+ expect(document.querySelector(".perspective-float-bubble")).toBeFalsy();
151
+ expect(document.querySelector(".perspective-float-window")).toBeFalsy();
152
+ });
153
+
154
+ it("destroy is alias for unmount", () => {
155
+ const handle = createFloatBubble({
156
+ researchId: "test-research-id",
157
+ });
158
+
159
+ expect(document.querySelector(".perspective-float-bubble")).toBeTruthy();
160
+
161
+ handle.destroy();
162
+
163
+ expect(document.querySelector(".perspective-float-bubble")).toBeFalsy();
164
+ });
165
+
166
+ it("open when already open is no-op", () => {
167
+ const handle = createFloatBubble({
168
+ researchId: "test-research-id",
169
+ });
170
+
171
+ handle.open();
172
+ const window1 = document.querySelector(".perspective-float-window");
173
+
174
+ handle.open();
175
+ const window2 = document.querySelector(".perspective-float-window");
176
+
177
+ expect(window1).toBe(window2);
178
+
179
+ handle.unmount();
180
+ });
181
+
182
+ it("close when already closed is no-op", () => {
183
+ const onClose = vi.fn();
184
+ const handle = createFloatBubble({
185
+ researchId: "test-research-id",
186
+ onClose,
187
+ });
188
+
189
+ handle.close();
190
+ handle.close();
191
+ handle.close();
192
+
193
+ expect(onClose).not.toHaveBeenCalled();
194
+
195
+ handle.unmount();
196
+ });
197
+
198
+ it("uses custom host", () => {
199
+ const handle = createFloatBubble({
200
+ researchId: "test-research-id",
201
+ host: "https://custom.example.com",
202
+ });
203
+
204
+ handle.open();
205
+
206
+ const iframe = handle.iframe as HTMLIFrameElement;
207
+ expect(iframe.src).toContain("https://custom.example.com");
208
+
209
+ handle.unmount();
210
+ });
211
+
212
+ it("update modifies config", () => {
213
+ const onSubmit1 = vi.fn();
214
+ const onSubmit2 = vi.fn();
215
+
216
+ const handle = createFloatBubble({
217
+ researchId: "test-research-id",
218
+ onSubmit: onSubmit1,
219
+ });
220
+
221
+ expect(() => handle.update({ onSubmit: onSubmit2 })).not.toThrow();
222
+
223
+ handle.unmount();
224
+ });
225
+
226
+ describe("update() behavior", () => {
227
+ const host = "https://getperspective.ai";
228
+ const researchId = "test-research-id";
229
+
230
+ const sendMessage = (
231
+ iframe: HTMLIFrameElement,
232
+ type: string,
233
+ extra?: Record<string, unknown>
234
+ ) => {
235
+ window.dispatchEvent(
236
+ new MessageEvent("message", {
237
+ data: { type, researchId, ...extra },
238
+ origin: host,
239
+ source: iframe.contentWindow,
240
+ })
241
+ );
242
+ };
243
+
244
+ it("update changes which callback is invoked", () => {
245
+ const onSubmit1 = vi.fn();
246
+ const onSubmit2 = vi.fn();
247
+
248
+ const handle = createFloatBubble({
249
+ researchId,
250
+ host,
251
+ onSubmit: onSubmit1,
252
+ });
253
+
254
+ handle.open();
255
+ handle.update({ onSubmit: onSubmit2 });
256
+
257
+ sendMessage(handle.iframe!, "perspective:submit");
258
+
259
+ expect(onSubmit2).toHaveBeenCalledTimes(1);
260
+ expect(onSubmit1).not.toHaveBeenCalled();
261
+
262
+ handle.unmount();
263
+ });
264
+
265
+ it("sequential updates only use latest callback", () => {
266
+ const fn1 = vi.fn();
267
+ const fn2 = vi.fn();
268
+ const fn3 = vi.fn();
269
+
270
+ const handle = createFloatBubble({
271
+ researchId,
272
+ host,
273
+ onSubmit: fn1,
274
+ });
275
+
276
+ handle.open();
277
+ handle.update({ onSubmit: fn2 });
278
+ handle.update({ onSubmit: fn3 });
279
+
280
+ sendMessage(handle.iframe!, "perspective:submit");
281
+
282
+ expect(fn3).toHaveBeenCalledTimes(1);
283
+ expect(fn2).not.toHaveBeenCalled();
284
+ expect(fn1).not.toHaveBeenCalled();
285
+
286
+ handle.unmount();
287
+ });
288
+
289
+ it("close prevents further callback invocations from that window", () => {
290
+ const onSubmit = vi.fn();
291
+ const onClose = vi.fn();
292
+
293
+ const handle = createFloatBubble({
294
+ researchId,
295
+ host,
296
+ onSubmit,
297
+ onClose,
298
+ });
299
+
300
+ handle.open();
301
+ const iframe = handle.iframe!;
302
+
303
+ handle.close();
304
+ expect(onClose).toHaveBeenCalledTimes(1);
305
+
306
+ sendMessage(iframe, "perspective:submit");
307
+
308
+ expect(onSubmit).not.toHaveBeenCalled();
309
+ });
310
+ });
311
+ });
312
+
313
+ describe("createChatBubble", () => {
314
+ afterEach(() => {
315
+ document.body.innerHTML = "";
316
+ });
317
+
318
+ it("is alias for createFloatBubble", () => {
319
+ expect(createChatBubble).toBe(createFloatBubble);
320
+ });
321
+
322
+ it("creates float bubble", () => {
323
+ const handle = createChatBubble({
324
+ researchId: "test-research-id",
325
+ });
326
+
327
+ expect(handle.type).toBe("float");
328
+ expect(document.querySelector(".perspective-float-bubble")).toBeTruthy();
329
+
330
+ handle.unmount();
331
+ });
332
+ });