@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/styles.ts ADDED
@@ -0,0 +1,395 @@
1
+ /**
2
+ * CSS styles injected by the embed script
3
+ * SSR-safe - DOM access is guarded
4
+ */
5
+
6
+ import { hasDom } from "./config";
7
+
8
+ let stylesInjected = false;
9
+
10
+ const LIGHT_THEME = `
11
+ --perspective-overlay-bg: rgba(0, 0, 0, 0.5);
12
+ --perspective-modal-bg: #ffffff;
13
+ --perspective-modal-text: #151B23;
14
+ --perspective-close-bg: rgba(0, 0, 0, 0.1);
15
+ --perspective-close-text: #666666;
16
+ --perspective-close-hover-bg: rgba(0, 0, 0, 0.2);
17
+ --perspective-close-hover-text: #333333;
18
+ --perspective-border: hsl(240 6% 90%);
19
+ `;
20
+
21
+ const DARK_THEME = `
22
+ --perspective-overlay-bg: rgba(0, 0, 0, 0.7);
23
+ --perspective-modal-bg: #02040a;
24
+ --perspective-modal-text: #ffffff;
25
+ --perspective-close-bg: rgba(255, 255, 255, 0.1);
26
+ --perspective-close-text: #a0a0a0;
27
+ --perspective-close-hover-bg: rgba(255, 255, 255, 0.2);
28
+ --perspective-close-hover-text: #ffffff;
29
+ --perspective-border: hsl(217 33% 17%);
30
+ `;
31
+
32
+ export function injectStyles(): void {
33
+ if (!hasDom()) return;
34
+ if (stylesInjected) return;
35
+ stylesInjected = true;
36
+
37
+ const style = document.createElement("style");
38
+ style.id = "perspective-embed-styles";
39
+ style.textContent = `
40
+ /* Theme-aware color variables */
41
+ .perspective-embed-root, .perspective-light-theme {
42
+ ${LIGHT_THEME}
43
+ --perspective-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
44
+ --perspective-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
45
+ --perspective-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
46
+ --perspective-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
47
+ --perspective-radius: 1.2rem;
48
+ --perspective-radius-sm: calc(var(--perspective-radius) - 4px);
49
+ }
50
+
51
+ /* Dark theme */
52
+ .perspective-dark-theme {
53
+ ${DARK_THEME}
54
+ }
55
+
56
+ /* System dark mode support */
57
+ @media (prefers-color-scheme: dark) {
58
+ .perspective-embed-root:not(.perspective-light-theme) {
59
+ ${DARK_THEME}
60
+ }
61
+ }
62
+
63
+ /* Scrollbar styling */
64
+ .perspective-modal,
65
+ .perspective-slider,
66
+ .perspective-float-window,
67
+ .perspective-chat-window {
68
+ scrollbar-width: thin;
69
+ scrollbar-color: var(--perspective-border) transparent;
70
+ }
71
+
72
+ .perspective-modal::-webkit-scrollbar,
73
+ .perspective-slider::-webkit-scrollbar,
74
+ .perspective-float-window::-webkit-scrollbar,
75
+ .perspective-chat-window::-webkit-scrollbar {
76
+ width: 10px;
77
+ height: 10px;
78
+ }
79
+
80
+ .perspective-modal::-webkit-scrollbar-track,
81
+ .perspective-slider::-webkit-scrollbar-track,
82
+ .perspective-float-window::-webkit-scrollbar-track,
83
+ .perspective-chat-window::-webkit-scrollbar-track {
84
+ background: transparent;
85
+ }
86
+
87
+ .perspective-modal::-webkit-scrollbar-thumb,
88
+ .perspective-slider::-webkit-scrollbar-thumb,
89
+ .perspective-float-window::-webkit-scrollbar-thumb,
90
+ .perspective-chat-window::-webkit-scrollbar-thumb {
91
+ background-color: var(--perspective-border);
92
+ border-radius: 9999px;
93
+ border: 2px solid transparent;
94
+ background-clip: padding-box;
95
+ }
96
+
97
+ .perspective-modal::-webkit-scrollbar-thumb:hover,
98
+ .perspective-slider::-webkit-scrollbar-thumb:hover,
99
+ .perspective-float-window::-webkit-scrollbar-thumb:hover,
100
+ .perspective-chat-window::-webkit-scrollbar-thumb:hover {
101
+ background-color: color-mix(in srgb, var(--perspective-border) 80%, currentColor);
102
+ }
103
+
104
+ /* Overlay for popup/modal */
105
+ .perspective-overlay {
106
+ position: fixed;
107
+ inset: 0;
108
+ background: var(--perspective-overlay-bg);
109
+ display: flex;
110
+ align-items: center;
111
+ justify-content: center;
112
+ z-index: 9999;
113
+ animation: perspective-fade-in 0.2s ease-out;
114
+ }
115
+
116
+ @keyframes perspective-fade-in {
117
+ from { opacity: 0; }
118
+ to { opacity: 1; }
119
+ }
120
+
121
+ @keyframes perspective-spin {
122
+ to { transform: rotate(360deg); }
123
+ }
124
+
125
+ /* Modal container */
126
+ .perspective-modal {
127
+ position: relative;
128
+ width: 90%;
129
+ max-width: 600px;
130
+ height: 80vh;
131
+ max-height: 700px;
132
+ background: var(--perspective-modal-bg);
133
+ color: var(--perspective-modal-text);
134
+ border-radius: var(--perspective-radius);
135
+ overflow: hidden;
136
+ box-shadow: var(--perspective-shadow-xl);
137
+ animation: perspective-slide-up 0.3s ease-out;
138
+ }
139
+
140
+ @keyframes perspective-slide-up {
141
+ from {
142
+ opacity: 0;
143
+ transform: translateY(20px) scale(0.95);
144
+ }
145
+ to {
146
+ opacity: 1;
147
+ transform: translateY(0) scale(1);
148
+ }
149
+ }
150
+
151
+ .perspective-modal iframe {
152
+ width: 100%;
153
+ height: 100%;
154
+ border: none;
155
+ }
156
+
157
+ /* Close button */
158
+ .perspective-close {
159
+ position: absolute;
160
+ top: 1rem;
161
+ right: 1.5rem;
162
+ width: 2rem;
163
+ height: 2rem;
164
+ border: none;
165
+ background: var(--perspective-close-bg);
166
+ color: var(--perspective-close-text);
167
+ border-radius: 50%;
168
+ cursor: pointer;
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: center;
172
+ font-size: 1rem;
173
+ z-index: 10;
174
+ transition: background-color 0.2s ease, color 0.2s ease;
175
+ }
176
+
177
+ .perspective-close:hover {
178
+ background: var(--perspective-close-hover-bg);
179
+ color: var(--perspective-close-hover-text);
180
+ }
181
+
182
+ .perspective-close:focus-visible {
183
+ outline: 2px solid currentColor;
184
+ outline-offset: 2px;
185
+ }
186
+
187
+ .perspective-close svg {
188
+ width: 1rem;
189
+ height: 1rem;
190
+ stroke-width: 2;
191
+ }
192
+
193
+ /* Slider drawer */
194
+ .perspective-slider {
195
+ position: fixed;
196
+ top: 0;
197
+ right: 0;
198
+ width: 100%;
199
+ max-width: 450px;
200
+ height: 100%;
201
+ background: var(--perspective-modal-bg);
202
+ color: var(--perspective-modal-text);
203
+ box-shadow: var(--perspective-shadow-xl);
204
+ z-index: 9999;
205
+ animation: perspective-slide-in 0.3s ease-out;
206
+ }
207
+
208
+ @keyframes perspective-slide-in {
209
+ from { transform: translateX(100%); }
210
+ to { transform: translateX(0); }
211
+ }
212
+
213
+ .perspective-slider iframe {
214
+ width: 100%;
215
+ height: 100%;
216
+ border: none;
217
+ }
218
+
219
+ .perspective-slider .perspective-close {
220
+ top: 1rem;
221
+ right: 2rem;
222
+ }
223
+
224
+ /* Slider backdrop */
225
+ .perspective-slider-backdrop {
226
+ position: fixed;
227
+ inset: 0;
228
+ background: var(--perspective-overlay-bg);
229
+ z-index: 9998;
230
+ animation: perspective-fade-in 0.2s ease-out;
231
+ }
232
+
233
+ /* Float bubble (and legacy chat-bubble alias) */
234
+ .perspective-float-bubble,
235
+ .perspective-chat-bubble {
236
+ position: fixed;
237
+ bottom: 1.5rem;
238
+ right: 1.5rem;
239
+ width: 3.75rem;
240
+ height: 3.75rem;
241
+ border-radius: 50%;
242
+ background: var(--perspective-float-bg, var(--perspective-chat-bg, #7629C8));
243
+ color: white;
244
+ border: none;
245
+ cursor: pointer;
246
+ box-shadow: var(--perspective-float-shadow, var(--perspective-chat-shadow, 0 4px 12px rgba(118, 41, 200, 0.4)));
247
+ z-index: 9996;
248
+ display: flex;
249
+ align-items: center;
250
+ justify-content: center;
251
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
252
+ }
253
+
254
+ .perspective-float-bubble:hover,
255
+ .perspective-chat-bubble:hover {
256
+ transform: scale(1.05);
257
+ box-shadow: var(--perspective-float-shadow-hover, var(--perspective-chat-shadow-hover, 0 6px 16px rgba(118, 41, 200, 0.5)));
258
+ }
259
+
260
+ .perspective-float-bubble:focus-visible,
261
+ .perspective-chat-bubble:focus-visible {
262
+ outline: 2px solid currentColor;
263
+ outline-offset: 2px;
264
+ }
265
+
266
+ .perspective-float-bubble svg,
267
+ .perspective-chat-bubble svg {
268
+ width: 1.75rem;
269
+ height: 1.75rem;
270
+ stroke-width: 2;
271
+ }
272
+
273
+ /* Float window (and legacy chat-window alias) */
274
+ .perspective-float-window,
275
+ .perspective-chat-window {
276
+ position: fixed;
277
+ bottom: 6.25rem;
278
+ right: 1.5rem;
279
+ width: 380px;
280
+ height: calc(100vh - 8.75rem);
281
+ max-height: 600px;
282
+ background: var(--perspective-modal-bg);
283
+ color: var(--perspective-modal-text);
284
+ border-radius: var(--perspective-radius);
285
+ overflow: hidden;
286
+ box-shadow: var(--perspective-shadow-xl);
287
+ z-index: 9997;
288
+ animation: perspective-float-open 0.3s ease-out;
289
+ }
290
+
291
+ @keyframes perspective-float-open {
292
+ from {
293
+ opacity: 0;
294
+ transform: translateY(20px) scale(0.9);
295
+ }
296
+ to {
297
+ opacity: 1;
298
+ transform: translateY(0) scale(1);
299
+ }
300
+ }
301
+
302
+ .perspective-float-window iframe,
303
+ .perspective-chat-window iframe {
304
+ width: 100%;
305
+ height: 100%;
306
+ border: none;
307
+ }
308
+
309
+ .perspective-float-window .perspective-close,
310
+ .perspective-chat-window .perspective-close {
311
+ top: 1rem;
312
+ right: 1.5rem;
313
+ }
314
+
315
+ /* Fullpage */
316
+ .perspective-fullpage {
317
+ position: fixed;
318
+ inset: 0;
319
+ z-index: 9999;
320
+ background: var(--perspective-modal-bg);
321
+ }
322
+
323
+ .perspective-fullpage iframe {
324
+ width: 100%;
325
+ height: 100%;
326
+ border: none;
327
+ }
328
+
329
+ /* Responsive */
330
+ @media (max-width: 640px) {
331
+ .perspective-modal {
332
+ width: 100%;
333
+ height: 100%;
334
+ max-width: none;
335
+ max-height: none;
336
+ border-radius: 0;
337
+ }
338
+
339
+ .perspective-slider {
340
+ max-width: 100%;
341
+ }
342
+
343
+ .perspective-float-window,
344
+ .perspective-chat-window {
345
+ width: calc(100% - 2rem);
346
+ right: 1rem;
347
+ bottom: 5.625rem;
348
+ height: calc(100vh - 7.5rem);
349
+ }
350
+
351
+ .perspective-float-bubble,
352
+ .perspective-chat-bubble {
353
+ bottom: 1rem;
354
+ right: 1rem;
355
+ }
356
+ }
357
+
358
+ @media (max-width: 450px) {
359
+ .perspective-float-window,
360
+ .perspective-chat-window {
361
+ width: calc(100% - 1rem);
362
+ right: 0.5rem;
363
+ bottom: 5rem;
364
+ height: calc(100vh - 6.5rem);
365
+ }
366
+
367
+ .perspective-float-bubble,
368
+ .perspective-chat-bubble {
369
+ bottom: 0.75rem;
370
+ right: 0.75rem;
371
+ width: 3.5rem;
372
+ height: 3.5rem;
373
+ }
374
+
375
+ .perspective-float-bubble svg,
376
+ .perspective-chat-bubble svg {
377
+ width: 1.5rem;
378
+ height: 1.5rem;
379
+ }
380
+ }
381
+ `;
382
+
383
+ document.head.appendChild(style);
384
+ }
385
+
386
+ export const MIC_ICON = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
387
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
388
+ </svg>`;
389
+
390
+ /** @deprecated Use MIC_ICON instead */
391
+ export const CHAT_ICON = MIC_ICON;
392
+
393
+ export const CLOSE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
394
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
395
+ </svg>`;
package/src/types.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Type definitions for the Perspective Embed SDK
3
+ * This file is TYPE-ONLY - no runtime value imports or exports
4
+ * SSR-safe by design
5
+ */
6
+
7
+ import type {
8
+ ThemeValue,
9
+ ParamKey,
10
+ BrandKey,
11
+ MessageType,
12
+ ErrorCode,
13
+ } from "./constants";
14
+
15
+ // Re-export types only
16
+ export type { ThemeValue, ParamKey, BrandKey, MessageType, ErrorCode };
17
+
18
+ export type EmbedType =
19
+ | "widget"
20
+ | "popup"
21
+ | "slider"
22
+ | "float"
23
+ | "fullpage"
24
+ | "chat";
25
+
26
+ /** Brand colors that can be passed via embed code */
27
+ export interface BrandColors {
28
+ /** Primary accent color (buttons, links, focus states) */
29
+ primary?: string;
30
+ /** Secondary accent color */
31
+ secondary?: string;
32
+ /** Background color of the embed */
33
+ bg?: string;
34
+ /** Primary text color */
35
+ text?: string;
36
+ }
37
+
38
+ export interface EmbedConfig {
39
+ researchId: string;
40
+ type?: EmbedType;
41
+ /** Custom button text for popup/slider triggers */
42
+ buttonText?: string;
43
+ /** Custom params to pass to the interview (for tracking/attribution) */
44
+ params?: Record<string, string>;
45
+ /** Brand colors to override Research settings */
46
+ brand?: {
47
+ light?: BrandColors;
48
+ dark?: BrandColors;
49
+ };
50
+ /** Force theme mode: 'dark', 'light', or 'system' (default) */
51
+ theme?: ThemeValue;
52
+ /** Override the default host (defaults to https://getperspective.ai) */
53
+ host?: string;
54
+ /** Callback when embed is ready */
55
+ onReady?: () => void;
56
+ /** Callback when interview is submitted/completed */
57
+ onSubmit?: (data: { researchId: string }) => void;
58
+ /** Callback when embed wants to navigate. If provided, parent handles navigation; otherwise SDK navigates via window.location.href */
59
+ onNavigate?: (url: string) => void;
60
+ /** Callback when embed is closed */
61
+ onClose?: () => void;
62
+ /** Callback on any error */
63
+ onError?: (error: EmbedError) => void;
64
+ }
65
+
66
+ /** Embed error with code for programmatic handling */
67
+ export interface EmbedError extends Error {
68
+ code?: ErrorCode;
69
+ }
70
+
71
+ export interface EmbedInstance {
72
+ researchId: string;
73
+ type: EmbedType;
74
+ iframe: HTMLIFrameElement;
75
+ container: HTMLElement;
76
+ destroy: () => void;
77
+ }
78
+
79
+ /** Handle returned by embed creation functions */
80
+ export interface EmbedHandle {
81
+ unmount: () => void;
82
+ update: (
83
+ options: Partial<
84
+ Pick<
85
+ EmbedConfig,
86
+ "onReady" | "onSubmit" | "onNavigate" | "onClose" | "onError"
87
+ >
88
+ >
89
+ ) => void;
90
+ /** @deprecated Use unmount() instead */
91
+ destroy: () => void;
92
+ /** @deprecated For legacy compatibility */
93
+ readonly researchId: string;
94
+ /** @deprecated For legacy compatibility */
95
+ readonly type: EmbedType;
96
+ /** @deprecated For legacy compatibility - may be null on server */
97
+ readonly iframe: HTMLIFrameElement | null;
98
+ /** @deprecated For legacy compatibility - may be null on server */
99
+ readonly container: HTMLElement | null;
100
+ }
101
+
102
+ /** Handle for float bubble with open/close control (persistent UI element) */
103
+ export interface FloatHandle extends Omit<EmbedHandle, "type"> {
104
+ open: () => void;
105
+ close: () => void;
106
+ toggle: () => void;
107
+ readonly isOpen: boolean;
108
+ readonly type: "float";
109
+ }
110
+
111
+ /** @deprecated Use FloatHandle for float bubble, EmbedHandle for popup/slider */
112
+ export type ModalHandle = FloatHandle;
113
+
114
+ /** Messages sent from SDK to iframe */
115
+ export interface InitMessage {
116
+ type: "perspective:init";
117
+ version: string;
118
+ features: number;
119
+ researchId: string;
120
+ }
121
+
122
+ /** Messages sent from iframe to SDK */
123
+ export type EmbedMessage =
124
+ | { type: "perspective:ready"; researchId: string }
125
+ | { type: "perspective:resize"; researchId: string; height: number }
126
+ | { type: "perspective:submit"; researchId: string; data?: unknown }
127
+ | { type: "perspective:close"; researchId: string }
128
+ | {
129
+ type: "perspective:error";
130
+ researchId: string;
131
+ error: string;
132
+ code?: string;
133
+ }
134
+ | { type: "perspective:redirect"; researchId: string; url: string };
135
+
136
+ /** Theme configuration from API */
137
+ export interface ThemeConfig {
138
+ primaryColor: string;
139
+ textColor: string;
140
+ darkPrimaryColor: string;
141
+ darkTextColor: string;
142
+ }
143
+
144
+ /** SDK global configuration */
145
+ export interface SDKConfig {
146
+ /** Override the default host */
147
+ host?: string;
148
+ }
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import {
3
+ cn,
4
+ getThemeClass,
5
+ resolveIsDark,
6
+ resolveTheme,
7
+ normalizeHex,
8
+ hexToRgba,
9
+ } from "./utils";
10
+ import * as config from "./config";
11
+
12
+ describe("cn", () => {
13
+ it("joins class names with spaces", () => {
14
+ expect(cn("foo", "bar", "baz")).toBe("foo bar baz");
15
+ });
16
+
17
+ it("filters out falsy values", () => {
18
+ expect(cn("foo", false, "bar", null, undefined, "baz")).toBe("foo bar baz");
19
+ });
20
+
21
+ it("handles empty strings", () => {
22
+ expect(cn("foo", "", "bar")).toBe("foo bar");
23
+ });
24
+
25
+ it("flattens space-separated classes", () => {
26
+ expect(cn("foo bar", "baz qux")).toBe("foo bar baz qux");
27
+ });
28
+
29
+ it("returns empty string for no valid classes", () => {
30
+ expect(cn(false, null, undefined)).toBe("");
31
+ });
32
+ });
33
+
34
+ describe("getThemeClass", () => {
35
+ it("returns theme class for light", () => {
36
+ expect(getThemeClass("light")).toBe("perspective-light-theme");
37
+ });
38
+
39
+ it("returns theme class for dark", () => {
40
+ expect(getThemeClass("dark")).toBe("perspective-dark-theme");
41
+ });
42
+
43
+ it("returns undefined for system theme", () => {
44
+ expect(getThemeClass("system")).toBeUndefined();
45
+ });
46
+
47
+ it("returns undefined for undefined", () => {
48
+ expect(getThemeClass(undefined)).toBeUndefined();
49
+ });
50
+ });
51
+
52
+ describe("resolveIsDark", () => {
53
+ beforeEach(() => {
54
+ vi.restoreAllMocks();
55
+ });
56
+
57
+ it("returns true for explicit dark theme", () => {
58
+ expect(resolveIsDark("dark")).toBe(true);
59
+ });
60
+
61
+ it("returns false for explicit light theme", () => {
62
+ expect(resolveIsDark("light")).toBe(false);
63
+ });
64
+
65
+ it("returns false when no DOM available", () => {
66
+ vi.spyOn(config, "hasDom").mockReturnValue(false);
67
+ expect(resolveIsDark("system")).toBe(false);
68
+ expect(resolveIsDark(undefined)).toBe(false);
69
+ });
70
+
71
+ it("uses system preference when theme is system and DOM available", () => {
72
+ vi.spyOn(config, "hasDom").mockReturnValue(true);
73
+
74
+ // Mock dark mode preference
75
+ Object.defineProperty(window, "matchMedia", {
76
+ writable: true,
77
+ value: vi.fn().mockImplementation((query: string) => ({
78
+ matches: query === "(prefers-color-scheme: dark)",
79
+ media: query,
80
+ addEventListener: vi.fn(),
81
+ removeEventListener: vi.fn(),
82
+ })),
83
+ });
84
+
85
+ expect(resolveIsDark("system")).toBe(true);
86
+ expect(resolveIsDark(undefined)).toBe(true);
87
+ });
88
+ });
89
+
90
+ describe("resolveTheme", () => {
91
+ beforeEach(() => {
92
+ vi.restoreAllMocks();
93
+ });
94
+
95
+ it("returns 'dark' for dark theme", () => {
96
+ expect(resolveTheme("dark")).toBe("dark");
97
+ });
98
+
99
+ it("returns 'light' for light theme", () => {
100
+ expect(resolveTheme("light")).toBe("light");
101
+ });
102
+
103
+ it("returns based on system when theme is system", () => {
104
+ vi.spyOn(config, "hasDom").mockReturnValue(false);
105
+ expect(resolveTheme("system")).toBe("light");
106
+ });
107
+ });
108
+
109
+ describe("normalizeHex", () => {
110
+ it("passes through valid 6-char hex with #", () => {
111
+ expect(normalizeHex("#ff0000")).toBe("#ff0000");
112
+ expect(normalizeHex("#AABBCC")).toBe("#AABBCC");
113
+ });
114
+
115
+ it("adds # prefix if missing", () => {
116
+ expect(normalizeHex("ff0000")).toBe("#ff0000");
117
+ expect(normalizeHex("abc")).toBe("#abc");
118
+ });
119
+
120
+ it("handles 3-char hex", () => {
121
+ expect(normalizeHex("#abc")).toBe("#abc");
122
+ expect(normalizeHex("ABC")).toBe("#ABC");
123
+ });
124
+
125
+ it("handles 8-char hex with alpha", () => {
126
+ expect(normalizeHex("#ff000080")).toBe("#ff000080");
127
+ });
128
+
129
+ it("returns undefined for empty string", () => {
130
+ expect(normalizeHex("")).toBeUndefined();
131
+ expect(normalizeHex(" ")).toBeUndefined();
132
+ });
133
+
134
+ it("extracts valid hex from invalid strings", () => {
135
+ // #gg0000 -> extracts "0000" -> returns 3-char "#000"
136
+ expect(normalizeHex("#gg0000")).toBe("#000");
137
+ // #ff00gg -> extracts "ff00" -> only 4 chars, returns 3-char "#ff0"
138
+ expect(normalizeHex("#ff00gg")).toBe("#ff0");
139
+ });
140
+
141
+ it("returns undefined for completely invalid input", () => {
142
+ expect(normalizeHex("notacolor")).toBeUndefined();
143
+ expect(normalizeHex("#gg")).toBeUndefined();
144
+ });
145
+ });
146
+
147
+ describe("hexToRgba", () => {
148
+ it("converts hex to rgba", () => {
149
+ expect(hexToRgba("#ff0000", 0.5)).toBe("rgba(255, 0, 0, 0.5)");
150
+ expect(hexToRgba("#00ff00", 1)).toBe("rgba(0, 255, 0, 1)");
151
+ expect(hexToRgba("#0000ff", 0)).toBe("rgba(0, 0, 255, 0)");
152
+ });
153
+
154
+ it("handles hex without #", () => {
155
+ expect(hexToRgba("ff0000", 0.5)).toBe("rgba(255, 0, 0, 0.5)");
156
+ });
157
+
158
+ it("returns default color for invalid hex", () => {
159
+ expect(hexToRgba("invalid", 0.5)).toBe("rgba(118, 41, 200, 0.5)");
160
+ expect(hexToRgba("#abc", 0.5)).toBe("rgba(118, 41, 200, 0.5)"); // 3-char hex not supported
161
+ });
162
+ });