@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/iframe.ts ADDED
@@ -0,0 +1,421 @@
1
+ /**
2
+ * iframe creation and postMessage communication
3
+ * SSR-safe - all DOM access is guarded
4
+ */
5
+
6
+ import type {
7
+ BrandColors,
8
+ EmbedConfig,
9
+ EmbedMessage,
10
+ EmbedType,
11
+ } from "./types";
12
+ import { hasDom } from "./config";
13
+ import {
14
+ UTM_PARAMS,
15
+ RESERVED_PARAMS,
16
+ PARAM_KEYS,
17
+ BRAND_KEYS,
18
+ MESSAGE_TYPES,
19
+ THEME_VALUES,
20
+ PARAM_VALUES,
21
+ STORAGE_KEYS,
22
+ SDK_VERSION,
23
+ CURRENT_FEATURES,
24
+ } from "./constants";
25
+ import { normalizeHex } from "./utils";
26
+
27
+ /** Validate redirect URL - allow https, http localhost, and relative URLs */
28
+ function isAllowedRedirectUrl(url: string): boolean {
29
+ if (!url || typeof url !== "string") return false;
30
+ try {
31
+ const parsed = new URL(url, window.location.origin);
32
+ const protocol = parsed.protocol.toLowerCase();
33
+ const hostname = parsed.hostname.toLowerCase();
34
+
35
+ if (protocol === "https:") return true;
36
+ if (
37
+ protocol === "http:" &&
38
+ (hostname === "localhost" || hostname === "127.0.0.1")
39
+ )
40
+ return true;
41
+
42
+ return false;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /** Get or create persistent anonymous ID */
49
+ function getOrCreateAnonId(): string {
50
+ if (!hasDom()) return "";
51
+
52
+ try {
53
+ let id = localStorage.getItem(STORAGE_KEYS.anonId);
54
+ if (!id) {
55
+ id = crypto.randomUUID();
56
+ localStorage.setItem(STORAGE_KEYS.anonId, id);
57
+ }
58
+ return id;
59
+ } catch {
60
+ // localStorage might be blocked
61
+ return crypto.randomUUID();
62
+ }
63
+ }
64
+
65
+ /** Collect UTM params from current page URL */
66
+ function getUtmParams(): Record<string, string> {
67
+ if (!hasDom()) return {};
68
+
69
+ const params: Record<string, string> = {};
70
+ const searchParams = new URLSearchParams(window.location.search);
71
+
72
+ for (const key of UTM_PARAMS) {
73
+ const value = searchParams.get(key);
74
+ if (value) {
75
+ params[key] = value;
76
+ }
77
+ }
78
+
79
+ return params;
80
+ }
81
+
82
+ /** Build iframe URL with all params */
83
+ function buildIframeUrl(
84
+ researchId: string,
85
+ type: EmbedType,
86
+ host: string,
87
+ customParams?: Record<string, string>,
88
+ brand?: { light?: BrandColors; dark?: BrandColors },
89
+ themeOverride?: "dark" | "light" | "system"
90
+ ): string {
91
+ const url = new URL(`${host}/interview/${researchId}`);
92
+
93
+ // Base embed params
94
+ url.searchParams.set(PARAM_KEYS.embed, PARAM_VALUES.true);
95
+ url.searchParams.set(PARAM_KEYS.embedType, type === "float" ? "chat" : type);
96
+
97
+ // Detect and pass system theme preference (can be overridden)
98
+ if (hasDom()) {
99
+ const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
100
+ if (themeOverride && themeOverride !== THEME_VALUES.system) {
101
+ url.searchParams.set(PARAM_KEYS.theme, themeOverride);
102
+ } else {
103
+ url.searchParams.set(
104
+ PARAM_KEYS.theme,
105
+ isDark ? THEME_VALUES.dark : THEME_VALUES.light
106
+ );
107
+ }
108
+ } else {
109
+ // SSR fallback
110
+ url.searchParams.set(PARAM_KEYS.theme, themeOverride || THEME_VALUES.light);
111
+ }
112
+
113
+ // Auto-forward UTM params from parent
114
+ const utmParams = getUtmParams();
115
+ for (const [key, value] of Object.entries(utmParams)) {
116
+ url.searchParams.set(key, value);
117
+ }
118
+
119
+ // Helper to set param only if color is valid
120
+ const setColor = (key: string, color: string | undefined) => {
121
+ if (!color) return;
122
+ const normalized = normalizeHex(color);
123
+ if (normalized) url.searchParams.set(key, normalized);
124
+ };
125
+
126
+ // Add brand colors using short keys
127
+ if (brand?.light) {
128
+ setColor(BRAND_KEYS.primary, brand.light.primary);
129
+ setColor(BRAND_KEYS.secondary, brand.light.secondary);
130
+ setColor(BRAND_KEYS.bg, brand.light.bg);
131
+ setColor(BRAND_KEYS.text, brand.light.text);
132
+ }
133
+
134
+ // Add dark mode brand colors
135
+ if (brand?.dark) {
136
+ setColor(BRAND_KEYS.darkPrimary, brand.dark.primary);
137
+ setColor(BRAND_KEYS.darkSecondary, brand.dark.secondary);
138
+ setColor(BRAND_KEYS.darkBg, brand.dark.bg);
139
+ setColor(BRAND_KEYS.darkText, brand.dark.text);
140
+ }
141
+
142
+ // Add custom params, filtering out reserved keys
143
+ if (customParams) {
144
+ for (const [key, value] of Object.entries(customParams)) {
145
+ if (!RESERVED_PARAMS.has(key)) {
146
+ url.searchParams.set(key, value);
147
+ }
148
+ }
149
+ }
150
+
151
+ return url.toString();
152
+ }
153
+
154
+ export function createIframe(
155
+ researchId: string,
156
+ type: EmbedType,
157
+ host: string,
158
+ params?: Record<string, string>,
159
+ brand?: { light?: BrandColors; dark?: BrandColors },
160
+ themeOverride?: "dark" | "light" | "system"
161
+ ): HTMLIFrameElement {
162
+ if (!hasDom()) {
163
+ // Return a stub for SSR
164
+ return {} as HTMLIFrameElement;
165
+ }
166
+
167
+ const iframe = document.createElement("iframe");
168
+ iframe.src = buildIframeUrl(
169
+ researchId,
170
+ type,
171
+ host,
172
+ params,
173
+ brand,
174
+ themeOverride
175
+ );
176
+ iframe.setAttribute("allow", "microphone; camera");
177
+ iframe.setAttribute("allowfullscreen", "true");
178
+ iframe.setAttribute(
179
+ "sandbox",
180
+ "allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-top-navigation"
181
+ );
182
+ iframe.setAttribute("data-perspective", "true");
183
+ iframe.style.cssText = "border:none;";
184
+
185
+ return iframe;
186
+ }
187
+
188
+ export function setupMessageListener(
189
+ researchId: string,
190
+ config: Partial<EmbedConfig>,
191
+ iframe: HTMLIFrameElement,
192
+ host: string,
193
+ options?: { skipResize?: boolean }
194
+ ): () => void {
195
+ if (!hasDom()) {
196
+ return () => {};
197
+ }
198
+
199
+ const handler = (event: MessageEvent<EmbedMessage>) => {
200
+ // Security: Only accept messages from our embed host and from the expected iframe
201
+ if (event.origin !== host) return;
202
+ if (event.source !== iframe.contentWindow) return;
203
+
204
+ // Only process messages from our embed
205
+ if (typeof event.data?.type !== "string") return;
206
+ if (!event.data.type.startsWith("perspective:")) return;
207
+ if (event.data.researchId !== researchId) return;
208
+
209
+ switch (event.data.type) {
210
+ case MESSAGE_TYPES.ready:
211
+ // Send scrollbar styles when iframe is ready
212
+ sendScrollbarStyles(iframe, host);
213
+ // Send anon_id for anonymous auth
214
+ sendMessage(iframe, host, {
215
+ type: MESSAGE_TYPES.anonId,
216
+ anonId: getOrCreateAnonId(),
217
+ });
218
+ // Send init message with version/features for handshake
219
+ sendMessage(iframe, host, {
220
+ type: MESSAGE_TYPES.init,
221
+ version: SDK_VERSION,
222
+ features: CURRENT_FEATURES,
223
+ researchId,
224
+ });
225
+ config.onReady?.();
226
+ break;
227
+
228
+ case MESSAGE_TYPES.resize:
229
+ // Auto-resize iframe height (skip for fixed-container embeds)
230
+ if (!options?.skipResize) {
231
+ iframe.style.height = `${event.data.height}px`;
232
+ }
233
+ break;
234
+
235
+ case MESSAGE_TYPES.submit:
236
+ config.onSubmit?.({ researchId });
237
+ break;
238
+
239
+ case MESSAGE_TYPES.close:
240
+ config.onClose?.();
241
+ break;
242
+
243
+ case MESSAGE_TYPES.error:
244
+ const error = new Error(
245
+ event.data.error
246
+ ) as import("./types").EmbedError;
247
+ error.code =
248
+ (event.data.code as import("./types").ErrorCode) || "UNKNOWN";
249
+ config.onError?.(error);
250
+ break;
251
+
252
+ case MESSAGE_TYPES.redirect:
253
+ const redirectUrl = event.data.url;
254
+ // Security: Only allow http(s) and localhost URLs
255
+ if (!isAllowedRedirectUrl(redirectUrl)) {
256
+ console.warn(
257
+ "[Perspective] Blocked unsafe redirect URL:",
258
+ redirectUrl
259
+ );
260
+ return;
261
+ }
262
+ if (config.onNavigate) {
263
+ config.onNavigate(redirectUrl);
264
+ } else {
265
+ // Fallback: auto-navigate parent page
266
+ window.location.href = redirectUrl;
267
+ }
268
+ break;
269
+ }
270
+ };
271
+
272
+ window.addEventListener("message", handler);
273
+ return () => window.removeEventListener("message", handler);
274
+ }
275
+
276
+ /** Send a message to the embed iframe */
277
+ export function sendMessage(
278
+ iframe: HTMLIFrameElement,
279
+ host: string,
280
+ message: { type: string; [key: string]: unknown }
281
+ ): void {
282
+ if (!hasDom()) return;
283
+ iframe.contentWindow?.postMessage(message, host);
284
+ }
285
+
286
+ /** Track all active iframes for theme change notifications */
287
+ const activeIframes = new Map<HTMLIFrameElement, string>();
288
+
289
+ export function registerIframe(
290
+ iframe: HTMLIFrameElement,
291
+ host: string
292
+ ): () => void {
293
+ activeIframes.set(iframe, host);
294
+ return () => {
295
+ activeIframes.delete(iframe);
296
+ if (activeIframes.size === 0) {
297
+ teardownGlobalListeners();
298
+ }
299
+ };
300
+ }
301
+
302
+ /** Get scrollbar CSS styles */
303
+ function getScrollbarStyles(): string {
304
+ if (!hasDom()) return "";
305
+
306
+ const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
307
+ const borderColor = isDark ? "hsl(217 33% 17%)" : "hsl(240 6% 90%)";
308
+
309
+ return `
310
+ * {
311
+ scrollbar-width: thin;
312
+ scrollbar-color: ${borderColor} transparent;
313
+ }
314
+ *::-webkit-scrollbar {
315
+ width: 10px;
316
+ height: 10px;
317
+ }
318
+ *::-webkit-scrollbar-track {
319
+ background: transparent;
320
+ }
321
+ *::-webkit-scrollbar-thumb {
322
+ background-color: ${borderColor};
323
+ border-radius: 9999px;
324
+ border: 2px solid transparent;
325
+ background-clip: padding-box;
326
+ }
327
+ *::-webkit-scrollbar-thumb:hover {
328
+ background-color: color-mix(in srgb, ${borderColor} 80%, currentColor);
329
+ }
330
+ `;
331
+ }
332
+
333
+ /** Send scrollbar styles to an iframe */
334
+ export function sendScrollbarStyles(
335
+ iframe: HTMLIFrameElement,
336
+ host: string
337
+ ): void {
338
+ const styles = getScrollbarStyles();
339
+ sendMessage(iframe, host, {
340
+ type: MESSAGE_TYPES.injectStyles,
341
+ styles,
342
+ });
343
+ }
344
+
345
+ /** Notify all active iframes of theme change */
346
+ export function notifyThemeChange(): void {
347
+ if (!hasDom()) return;
348
+
349
+ const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
350
+
351
+ activeIframes.forEach((host, iframe) => {
352
+ const message = {
353
+ type: MESSAGE_TYPES.themeChange,
354
+ theme: isDark ? THEME_VALUES.dark : THEME_VALUES.light,
355
+ };
356
+ sendMessage(iframe, host, message);
357
+ sendScrollbarStyles(iframe, host);
358
+ });
359
+ }
360
+
361
+ let themeListener: ((e: MediaQueryListEvent) => void) | null = null;
362
+ let themeMediaQuery: MediaQueryList | null = null;
363
+ let globalMessageHandler: ((event: MessageEvent) => void) | null = null;
364
+ let globalListenersInitialized = false;
365
+
366
+ function setupThemeListener(): void {
367
+ if (themeListener || !hasDom()) return;
368
+
369
+ themeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
370
+ themeListener = () => notifyThemeChange();
371
+ themeMediaQuery.addEventListener("change", themeListener);
372
+ }
373
+
374
+ function teardownThemeListener(): void {
375
+ if (themeListener && themeMediaQuery) {
376
+ themeMediaQuery.removeEventListener("change", themeListener);
377
+ themeListener = null;
378
+ themeMediaQuery = null;
379
+ }
380
+ }
381
+
382
+ function setupGlobalListeners(): void {
383
+ if (!hasDom() || globalMessageHandler) return;
384
+
385
+ setupThemeListener();
386
+
387
+ globalMessageHandler = (event: MessageEvent) => {
388
+ if (!event.data?.type?.startsWith("perspective:")) return;
389
+ if (event.data.type === MESSAGE_TYPES.requestScrollbarStyles) {
390
+ const iframes = Array.from(
391
+ document.querySelectorAll("iframe[data-perspective]")
392
+ );
393
+ const sourceIframe = iframes.find(
394
+ (iframe) => (iframe as HTMLIFrameElement).contentWindow === event.source
395
+ ) as HTMLIFrameElement | undefined;
396
+ if (sourceIframe) {
397
+ const host = activeIframes.get(sourceIframe);
398
+ if (host && event.origin === host) {
399
+ sendScrollbarStyles(sourceIframe, host);
400
+ }
401
+ }
402
+ }
403
+ };
404
+
405
+ window.addEventListener("message", globalMessageHandler);
406
+ }
407
+
408
+ function teardownGlobalListeners(): void {
409
+ if (globalMessageHandler) {
410
+ window.removeEventListener("message", globalMessageHandler);
411
+ globalMessageHandler = null;
412
+ }
413
+ teardownThemeListener();
414
+ globalListenersInitialized = false;
415
+ }
416
+
417
+ export function ensureGlobalListeners(): void {
418
+ if (globalListenersInitialized) return;
419
+ globalListenersInitialized = true;
420
+ setupGlobalListeners();
421
+ }
package/src/index.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Perspective Embed SDK
3
+ *
4
+ * NPM Entry Point - clean exports, no auto-init, SSR-safe
5
+ *
6
+ * Usage:
7
+ * import { createWidget, openPopup, openSlider, createFloatBubble } from '@perspective-ai/embed';
8
+ *
9
+ * // Inline widget
10
+ * const widget = createWidget(container, { researchId: 'xxx' });
11
+ *
12
+ * // Popup modal
13
+ * const popup = openPopup({ researchId: 'xxx' });
14
+ *
15
+ * // Slider panel
16
+ * const slider = openSlider({ researchId: 'xxx' });
17
+ *
18
+ * // Floating bubble
19
+ * const bubble = createFloatBubble({ researchId: 'xxx' });
20
+ *
21
+ * For SSR-safe constants and types:
22
+ * import { DATA_ATTRS, MESSAGE_TYPES } from '@perspective-ai/embed/constants';
23
+ */
24
+
25
+ // Core embed functions
26
+ export { createWidget } from "./widget";
27
+ export { openPopup } from "./popup";
28
+ export { openSlider } from "./slider";
29
+ export { createFloatBubble, createChatBubble } from "./float";
30
+ export { createFullpage } from "./fullpage";
31
+
32
+ // Configuration
33
+ export { configure, getConfig } from "./config";
34
+
35
+ // Types
36
+ export type {
37
+ BrandColors,
38
+ EmbedConfig,
39
+ EmbedHandle,
40
+ FloatHandle,
41
+ ModalHandle,
42
+ EmbedInstance,
43
+ EmbedError,
44
+ EmbedType,
45
+ ThemeConfig,
46
+ SDKConfig,
47
+ } from "./types";
48
+
49
+ // Re-export commonly used constants and types
50
+ export {
51
+ SDK_VERSION,
52
+ FEATURES,
53
+ CURRENT_FEATURES,
54
+ DATA_ATTRS,
55
+ PARAM_KEYS,
56
+ BRAND_KEYS,
57
+ MESSAGE_TYPES,
58
+ THEME_VALUES,
59
+ } from "./constants";
60
+
61
+ export type { ThemeValue, ParamKey, BrandKey, MessageType } from "./constants";
package/src/loading.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Loading indicator for embed iframes
3
+ * SSR-safe - returns no-op on server
4
+ */
5
+
6
+ import type { BrandColors, ThemeValue } from "./types";
7
+ import { hasDom } from "./config";
8
+ import { resolveTheme, hexToRgba } from "./utils";
9
+
10
+ /** Default colors matching codebase theme */
11
+ const DEFAULT_COLORS = {
12
+ light: {
13
+ bg: "#ffffff",
14
+ primary: "#7629C8",
15
+ },
16
+ dark: {
17
+ bg: "#02040a",
18
+ primary: "#B170FF",
19
+ },
20
+ };
21
+
22
+ export interface LoadingOptions {
23
+ /** Theme override: 'dark', 'light', or 'system' (uses system preference) */
24
+ theme?: ThemeValue;
25
+ /** Brand colors - uses primary color for spinner */
26
+ brand?: {
27
+ light?: BrandColors;
28
+ dark?: BrandColors;
29
+ };
30
+ }
31
+
32
+ /** Get colors for loading indicator based on theme and brand */
33
+ function getLoadingColors(options?: LoadingOptions): {
34
+ bg: string;
35
+ primary: string;
36
+ } {
37
+ const theme = resolveTheme(options?.theme);
38
+ const isDark = theme === "dark";
39
+
40
+ // Get brand colors for current theme
41
+ const brandColors = isDark ? options?.brand?.dark : options?.brand?.light;
42
+
43
+ return {
44
+ bg:
45
+ brandColors?.bg ||
46
+ (isDark ? DEFAULT_COLORS.dark.bg : DEFAULT_COLORS.light.bg),
47
+ primary:
48
+ brandColors?.primary ||
49
+ (isDark ? DEFAULT_COLORS.dark.primary : DEFAULT_COLORS.light.primary),
50
+ };
51
+ }
52
+
53
+ export function createLoadingIndicator(options?: LoadingOptions): HTMLElement {
54
+ // SSR safety - return empty div on server
55
+ if (!hasDom()) {
56
+ return { remove: () => {}, style: {} } as unknown as HTMLElement;
57
+ }
58
+
59
+ const colors = getLoadingColors(options);
60
+
61
+ const container = document.createElement("div");
62
+ container.className = "perspective-loading";
63
+ container.style.cssText = `
64
+ position: absolute;
65
+ top: 0;
66
+ left: 0;
67
+ right: 0;
68
+ bottom: 0;
69
+ display: flex;
70
+ align-items: center;
71
+ justify-content: center;
72
+ background: ${colors.bg};
73
+ transition: opacity 0.3s ease;
74
+ z-index: 1;
75
+ `;
76
+
77
+ // Create spinner (keyframes defined in styles.ts)
78
+ const spinner = document.createElement("div");
79
+ spinner.style.cssText = `
80
+ width: 2.5rem;
81
+ height: 2.5rem;
82
+ border: 3px solid ${hexToRgba(colors.primary, 0.15)};
83
+ border-top-color: ${colors.primary};
84
+ border-radius: 50%;
85
+ animation: perspective-spin 0.8s linear infinite;
86
+ `;
87
+
88
+ container.appendChild(spinner);
89
+ return container;
90
+ }