onboardme-sdk 0.0.1

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 (69) hide show
  1. package/ARCHITECTURE-v2.md +225 -0
  2. package/dist/sdk.iife.js +348 -0
  3. package/package.json +22 -0
  4. package/src/__tests__/day1.test.ts +37 -0
  5. package/src/__tests__/day2.test.ts +447 -0
  6. package/src/__tests__/day3.test.ts +110 -0
  7. package/src/__tests__/day4.test.ts +115 -0
  8. package/src/__tests__/day5.test.ts +102 -0
  9. package/src/__tests__/snapshot-dom-collector.test.ts +153 -0
  10. package/src/__tests__/snapshot-sender.test.ts +111 -0
  11. package/src/__tests__/v2-integration.test.ts +305 -0
  12. package/src/__tests__/v2-positioner.test.ts +115 -0
  13. package/src/__tests__/v2-renderer.test.ts +189 -0
  14. package/src/__tests__/v2-types.test.ts +74 -0
  15. package/src/__tests__/week2-day1.test.ts +62 -0
  16. package/src/__tests__/week2-day2.test.ts +128 -0
  17. package/src/__tests__/week2-day3.test.ts +128 -0
  18. package/src/__tests__/week2-day4.test.ts +177 -0
  19. package/src/__tests__/week2-day5.test.ts +294 -0
  20. package/src/__tests__/week3-day1.test.ts +169 -0
  21. package/src/__tests__/week3-day2.test.ts +267 -0
  22. package/src/__tests__/week3-day3.test.ts +213 -0
  23. package/src/__tests__/week3-day4.test.ts +213 -0
  24. package/src/__tests__/week3-day5.test.ts +350 -0
  25. package/src/__tests__/week4-day1.test.ts +277 -0
  26. package/src/__tests__/week4-day2.test.ts +227 -0
  27. package/src/__tests__/week4-day3.test.ts +323 -0
  28. package/src/__tests__/week4-day4.test.ts +210 -0
  29. package/src/__tests__/week4-day5.test.ts +503 -0
  30. package/src/__tests__/week5-day1.test.ts +152 -0
  31. package/src/__tests__/week5-day2.test.ts +222 -0
  32. package/src/__tests__/week5-day3.test.ts +297 -0
  33. package/src/__tests__/week5-day4.test.ts +306 -0
  34. package/src/__tests__/week5-day5.test.ts +345 -0
  35. package/src/__tests__/week7-day5-api-flows.test.ts +353 -0
  36. package/src/auto-generate/context-collector.ts +47 -0
  37. package/src/auto-generate/flow-generator-client.ts +97 -0
  38. package/src/browser.ts +5 -0
  39. package/src/components/celebration.ts +44 -0
  40. package/src/components/checklist-css.ts +159 -0
  41. package/src/components/checklist.ts +295 -0
  42. package/src/components/modal-css.ts +96 -0
  43. package/src/components/modal.ts +171 -0
  44. package/src/components/shadow-host.ts +30 -0
  45. package/src/core/api-client.ts +39 -0
  46. package/src/core/api-flows.ts +204 -0
  47. package/src/core/config.ts +37 -0
  48. package/src/core/event-batcher.ts +169 -0
  49. package/src/core/sdk.ts +301 -0
  50. package/src/detection/user-detection.ts +55 -0
  51. package/src/index.ts +95 -0
  52. package/src/snapshot/dom-collector.ts +193 -0
  53. package/src/snapshot/sender.ts +105 -0
  54. package/src/storage/event-listener.ts +59 -0
  55. package/src/storage/progress-tracker.ts +78 -0
  56. package/src/styles/checklist-css.ts +159 -0
  57. package/src/styles/checklist.css +166 -0
  58. package/src/styles/modal-css.ts +96 -0
  59. package/src/styles/modal.css +102 -0
  60. package/src/utils/dom.ts +49 -0
  61. package/src/utils/fingerprint.ts +20 -0
  62. package/src/utils/logger.ts +17 -0
  63. package/src/v2/positioner.ts +105 -0
  64. package/src/v2/renderer.ts +287 -0
  65. package/src/v2/styles.ts +89 -0
  66. package/src/v2/types.ts +53 -0
  67. package/tsconfig.json +11 -0
  68. package/vite.config.ts +28 -0
  69. package/vitest.config.ts +7 -0
@@ -0,0 +1,295 @@
1
+ /**
2
+ * checklist.ts — Week 3 Days 2, 3 & 4
3
+ *
4
+ * Renders the onboarding checklist panel into a Shadow DOM root and wires
5
+ * all interactivity: item click → mark complete, progress bar update,
6
+ * collapse/expand pill behaviour, and completionEvent auto-check.
7
+ */
8
+
9
+ import type { FlowConfig, ChecklistItem } from '@onboardme/types';
10
+ import type { Progress } from '../storage/progress-tracker.js';
11
+ import { markStepComplete } from '../storage/progress-tracker.js';
12
+ import { watchCompletionEvents } from '../storage/event-listener.js';
13
+ import { CHECKLIST_CSS } from '../styles/checklist-css.js';
14
+ import { pushEvent } from '../core/event-batcher.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Per-shadow-root watcher cleanup
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Stores the active unsubscribe function for each shadow root so that
22
+ * re-rendering cleans up the previous watcher before installing a new one.
23
+ */
24
+ const _unsubscribers = new WeakMap<ShadowRoot, () => void>();
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ function injectStyles(shadowRoot: ShadowRoot): void {
31
+ const style = shadowRoot.querySelector<HTMLStyleElement>('style[data-onboardme="styles"]');
32
+ if (style) {
33
+ if (!style.textContent?.includes('.om-checklist')) {
34
+ style.textContent = (style.textContent ?? '') + CHECKLIST_CSS;
35
+ }
36
+ }
37
+ }
38
+
39
+ function getChecklistItems(flow: FlowConfig): ChecklistItem[] {
40
+ const checklistStep = flow.steps.find((s) => s.type === 'checklist');
41
+ return checklistStep?.items ?? [];
42
+ }
43
+
44
+ function countRequired(items: ChecklistItem[]): number {
45
+ return items.filter((item) => item.required !== false).length;
46
+ }
47
+
48
+ function buildItemEl(item: ChecklistItem, isDone: boolean): HTMLLIElement {
49
+ const li = document.createElement('li');
50
+ const classes = ['om-checklist__item'];
51
+ if (isDone) classes.push('om-checklist__item--done');
52
+ if (item.required === false) classes.push('om-checklist__item--optional');
53
+ li.className = classes.join(' ');
54
+ li.dataset['itemId'] = item.id;
55
+
56
+ const check = document.createElement('span');
57
+ check.className = 'om-checklist__item-check';
58
+ check.setAttribute('aria-hidden', 'true');
59
+ check.textContent = isDone ? '✓' : '';
60
+
61
+ const label = document.createElement('span');
62
+ label.className = 'om-checklist__item-label';
63
+ label.textContent = item.label;
64
+
65
+ li.appendChild(check);
66
+ li.appendChild(label);
67
+
68
+ if (item.required === false) {
69
+ const badge = document.createElement('span');
70
+ badge.className = 'om-badge';
71
+ badge.textContent = 'Optional';
72
+ li.appendChild(badge);
73
+ }
74
+
75
+ return li;
76
+ }
77
+
78
+ /**
79
+ * Surgically updates the progress bar fill, progress label, and collapsed
80
+ * count without touching the item list.
81
+ */
82
+ function updateProgressDisplay(
83
+ shadowRoot: ShadowRoot,
84
+ items: ChecklistItem[],
85
+ completedSteps: string[],
86
+ ): void {
87
+ const totalRequired = countRequired(items);
88
+ const completedRequired = items.filter(
89
+ (i) => i.required !== false && completedSteps.includes(i.id),
90
+ ).length;
91
+ const totalAll = items.length;
92
+ const completedAll = items.filter((i) => completedSteps.includes(i.id)).length;
93
+ const fillPct = totalRequired > 0 ? Math.round((completedRequired / totalRequired) * 100) : 0;
94
+
95
+ const fill = shadowRoot.querySelector<HTMLElement>('.om-checklist__progress-bar-fill');
96
+ if (fill) fill.style.width = `${fillPct}%`;
97
+
98
+ const label = shadowRoot.querySelector('[data-onboardme="progress-label"]');
99
+ if (label) label.textContent = `${completedAll} of ${totalAll} complete`;
100
+
101
+ const collapsedCount = shadowRoot.querySelector('[data-onboardme="collapsed-count"]');
102
+ if (collapsedCount) collapsedCount.textContent = `${completedAll} / ${totalAll}`;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Public API
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Renders (or re-renders) the checklist panel into the provided Shadow DOM.
111
+ *
112
+ * - Finds the first step with type === 'checklist' in the flow
113
+ * - Sorts items by item.order (never array index)
114
+ * - Warns (console.warn) in debug mode when items.length > 7
115
+ * - Marks already-completed items from progress.completedSteps on first render
116
+ * - Replaces any existing panel so calling this twice does not duplicate it
117
+ * - Wires item click → markStepComplete → visual update (Day 3)
118
+ * - Wires collapse button and panel click for collapse/expand (Day 3)
119
+ *
120
+ * @param productId Used by markStepComplete — pass empty string in tests that
121
+ * do not need persistence (keeps backwards compat).
122
+ * @param flowId Same as above.
123
+ */
124
+ export function renderChecklist(
125
+ flow: FlowConfig,
126
+ progress: Progress,
127
+ shadowRoot: ShadowRoot,
128
+ debug = false,
129
+ productId = '',
130
+ flowId = '',
131
+ ): void {
132
+ injectStyles(shadowRoot);
133
+
134
+ // Clean up previous completionEvent watcher before replacing the panel
135
+ _unsubscribers.get(shadowRoot)?.();
136
+ _unsubscribers.delete(shadowRoot);
137
+
138
+ const existing = shadowRoot.querySelector('[data-onboardme="checklist"]');
139
+ if (existing) shadowRoot.removeChild(existing);
140
+
141
+ const rawItems = getChecklistItems(flow);
142
+
143
+ if (debug && rawItems.length > 7) {
144
+ console.warn('[OnboardMe] Checklist has more than 7 items — consider splitting the flow.');
145
+ }
146
+
147
+ const items = [...rawItems].sort((a, b) => a.order - b.order);
148
+
149
+ const totalRequired = countRequired(items);
150
+ const completedRequired = items.filter(
151
+ (item) => item.required !== false && progress.completedSteps.includes(item.id),
152
+ ).length;
153
+ const totalAll = items.length;
154
+ const completedAll = items.filter((item) => progress.completedSteps.includes(item.id)).length;
155
+ const fillPct = totalRequired > 0 ? Math.round((completedRequired / totalRequired) * 100) : 0;
156
+
157
+ // ---- Mutable local snapshot of completedSteps ----------------------------
158
+ // Keeps click handlers in sync without re-reading localStorage on every click.
159
+ const mutableCompleted = [...progress.completedSteps];
160
+
161
+ // ---- Outer panel ---------------------------------------------------------
162
+ const panel = document.createElement('div');
163
+ panel.className = 'om-checklist';
164
+ panel.dataset['onboardme'] = 'checklist';
165
+
166
+ // ---- Header --------------------------------------------------------------
167
+ const header = document.createElement('div');
168
+ header.className = 'om-checklist__header';
169
+
170
+ const titleEl = document.createElement('span');
171
+ titleEl.className = 'om-checklist__title';
172
+ titleEl.textContent = 'Getting started';
173
+
174
+ // Shown only in collapsed state — holds "2 / 5"
175
+ const collapsedCountEl = document.createElement('span');
176
+ collapsedCountEl.className = 'om-checklist__collapsed-count';
177
+ collapsedCountEl.dataset['onboardme'] = 'collapsed-count';
178
+ collapsedCountEl.style.display = 'none';
179
+ collapsedCountEl.textContent = `${completedAll} / ${totalAll}`;
180
+
181
+ const collapseBtn = document.createElement('button');
182
+ collapseBtn.className = 'om-checklist__collapse-btn';
183
+ collapseBtn.type = 'button';
184
+ collapseBtn.dataset['onboardme'] = 'checklist-collapse';
185
+ collapseBtn.setAttribute('aria-label', 'Collapse checklist');
186
+ collapseBtn.textContent = '−';
187
+
188
+ header.appendChild(titleEl);
189
+ header.appendChild(collapsedCountEl);
190
+ header.appendChild(collapseBtn);
191
+
192
+ // ---- Progress area -------------------------------------------------------
193
+ const progressArea = document.createElement('div');
194
+ progressArea.className = 'om-checklist__progress';
195
+
196
+ const bar = document.createElement('div');
197
+ bar.className = 'om-checklist__progress-bar';
198
+
199
+ const fill = document.createElement('div');
200
+ fill.className = 'om-checklist__progress-bar-fill';
201
+ fill.style.width = `${fillPct}%`;
202
+ bar.appendChild(fill);
203
+
204
+ const progressLabel = document.createElement('span');
205
+ progressLabel.className = 'om-checklist__progress-label';
206
+ progressLabel.dataset['onboardme'] = 'progress-label';
207
+ progressLabel.textContent = `${completedAll} of ${totalAll} complete`;
208
+
209
+ progressArea.appendChild(bar);
210
+ progressArea.appendChild(progressLabel);
211
+
212
+ // ---- Items list ----------------------------------------------------------
213
+ const ul = document.createElement('ul');
214
+ ul.className = 'om-checklist__items';
215
+
216
+ for (const item of items) {
217
+ const isDone = progress.completedSteps.includes(item.id);
218
+ ul.appendChild(buildItemEl(item, isDone));
219
+ }
220
+
221
+ // ---- Assemble ------------------------------------------------------------
222
+ panel.appendChild(header);
223
+ panel.appendChild(progressArea);
224
+ panel.appendChild(ul);
225
+ shadowRoot.appendChild(panel);
226
+
227
+ // Emit step_viewed for the checklist step
228
+ const checklistStepId = flow.steps.find((s) => s.type === 'checklist')?.id ?? '';
229
+ if (checklistStepId && flowId) {
230
+ pushEvent('step_viewed', { flowId, stepId: checklistStepId });
231
+ }
232
+
233
+ // ---- Wire item click handlers --------------------------------------------
234
+ const itemEls = panel.querySelectorAll<HTMLLIElement>('.om-checklist__item');
235
+ for (const li of itemEls) {
236
+ li.addEventListener('click', () => {
237
+ const itemId = li.dataset['itemId'];
238
+ if (!itemId || mutableCompleted.includes(itemId)) return;
239
+
240
+ // Persist + emit event
241
+ if (productId && flowId) {
242
+ markStepComplete(productId, flowId, itemId);
243
+ pushEvent('checklist_item_done', { flowId, stepId: itemId });
244
+ }
245
+ mutableCompleted.push(itemId);
246
+
247
+ // Visual update — item
248
+ li.classList.add('om-checklist__item--done');
249
+ const checkEl = li.querySelector('.om-checklist__item-check');
250
+ if (checkEl) checkEl.textContent = '✓';
251
+
252
+ // Visual update — progress bar + label
253
+ updateProgressDisplay(shadowRoot, items, mutableCompleted);
254
+ });
255
+ }
256
+
257
+ // ---- Wire collapse / expand ----------------------------------------------
258
+ collapseBtn.addEventListener('click', (e) => {
259
+ e.stopPropagation(); // prevent the panel-level click from immediately re-expanding
260
+ panel.classList.add('om-checklist--collapsed');
261
+ titleEl.style.display = 'none';
262
+ collapsedCountEl.style.display = 'inline';
263
+ });
264
+
265
+ panel.addEventListener('click', () => {
266
+ if (!panel.classList.contains('om-checklist--collapsed')) return;
267
+ panel.classList.remove('om-checklist--collapsed');
268
+ titleEl.style.display = '';
269
+ collapsedCountEl.style.display = 'none';
270
+ });
271
+
272
+ // ---- Wire completionEvent auto-check (Day 4) -----------------------------
273
+ const unsubscribe = watchCompletionEvents(flow, (itemId) => {
274
+ if (mutableCompleted.includes(itemId)) return;
275
+
276
+ if (productId && flowId) {
277
+ markStepComplete(productId, flowId, itemId);
278
+ pushEvent('checklist_item_done', { flowId, stepId: itemId });
279
+ }
280
+ mutableCompleted.push(itemId);
281
+
282
+ // Visual update — item
283
+ const li = panel.querySelector<HTMLLIElement>(`[data-item-id="${itemId}"]`);
284
+ if (li) {
285
+ li.classList.add('om-checklist__item--done');
286
+ const checkEl = li.querySelector('.om-checklist__item-check');
287
+ if (checkEl) checkEl.textContent = '✓';
288
+ }
289
+
290
+ // Visual update — progress bar + label
291
+ updateProgressDisplay(shadowRoot, items, mutableCompleted);
292
+ });
293
+
294
+ _unsubscribers.set(shadowRoot, unsubscribe);
295
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Modal CSS as an injectable string for the Shadow DOM.
3
+ * Source of truth: modal.css (kept alongside for readability/tooling).
4
+ */
5
+ export const MODAL_CSS = `
6
+ .om-overlay {
7
+ position: fixed;
8
+ inset: 0;
9
+ background: rgba(0, 0, 0, 0.45);
10
+ display: flex;
11
+ align-items: center;
12
+ justify-content: center;
13
+ z-index: 2147483647;
14
+ }
15
+
16
+ .om-modal {
17
+ background: #ffffff;
18
+ border-radius: 12px;
19
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
20
+ max-width: 480px;
21
+ width: calc(100% - 32px);
22
+ padding: 32px;
23
+ box-sizing: border-box;
24
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
25
+ color: #111827;
26
+ }
27
+
28
+ .om-modal__title {
29
+ margin: 0 0 12px;
30
+ font-size: 20px;
31
+ font-weight: 700;
32
+ line-height: 1.3;
33
+ color: #111827;
34
+ }
35
+
36
+ .om-modal__body {
37
+ margin: 0 0 24px;
38
+ font-size: 15px;
39
+ line-height: 1.6;
40
+ color: #4b5563;
41
+ }
42
+
43
+ .om-modal__actions {
44
+ display: flex;
45
+ flex-direction: column;
46
+ gap: 10px;
47
+ align-items: stretch;
48
+ }
49
+
50
+ .om-btn-primary {
51
+ display: block;
52
+ width: 100%;
53
+ padding: 12px 20px;
54
+ background: #4f46e5;
55
+ color: #ffffff;
56
+ font-size: 15px;
57
+ font-weight: 600;
58
+ border: none;
59
+ border-radius: 8px;
60
+ cursor: pointer;
61
+ text-align: center;
62
+ transition: background 0.15s ease;
63
+ }
64
+
65
+ .om-btn-primary:hover {
66
+ background: #4338ca;
67
+ }
68
+
69
+ .om-btn-primary:focus-visible {
70
+ outline: 3px solid #818cf8;
71
+ outline-offset: 2px;
72
+ }
73
+
74
+ .om-btn-skip {
75
+ display: block;
76
+ background: none;
77
+ border: none;
78
+ padding: 6px 0;
79
+ color: #6b7280;
80
+ font-size: 14px;
81
+ cursor: pointer;
82
+ text-align: center;
83
+ text-decoration: underline;
84
+ transition: color 0.15s ease;
85
+ }
86
+
87
+ .om-btn-skip:hover {
88
+ color: #374151;
89
+ }
90
+
91
+ .om-btn-skip:focus-visible {
92
+ outline: 2px solid #818cf8;
93
+ outline-offset: 2px;
94
+ border-radius: 4px;
95
+ }
96
+ `;
@@ -0,0 +1,171 @@
1
+ import type { FlowStep } from '@onboardme/types';
2
+ import { MODAL_CSS } from '../styles/modal-css.js';
3
+ import { pushEvent } from '../core/event-batcher.js';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Internal state
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /**
10
+ * Maps each ShadowRoot to the keydown handler registered for it.
11
+ * Using a WeakMap ensures each shadow root instance has its own independent
12
+ * handler — no cross-instance interference.
13
+ */
14
+ const _keyHandlers = new WeakMap<ShadowRoot, (e: KeyboardEvent) => void>();
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ function sessionKey(productId: string): string {
21
+ return `onboardme_modal_shown_${productId}`;
22
+ }
23
+
24
+ // CSS is injected once into the shadow root's placeholder <style> element
25
+ // (created by getShadowRoot() in Day 1).
26
+ function injectStyles(shadowRoot: ShadowRoot): void {
27
+ const style = shadowRoot.querySelector<HTMLStyleElement>('style[data-onboardme="styles"]');
28
+ if (style && !style.textContent) {
29
+ style.textContent = MODAL_CSS;
30
+ }
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Public API
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Renders the welcome modal DOM structure into the provided Shadow DOM root.
39
+ *
40
+ * - Reads title/body from the FlowStep (plain text — no markdown yet)
41
+ * - Primary CTA defaults to "Get started" if step.primaryCta is absent
42
+ * - Skip link defaults to "Skip for now" if step.secondaryCta is absent
43
+ * - Skip link is hidden (not rendered) when step.dismissible === false
44
+ */
45
+ export function renderModal(step: FlowStep, shadowRoot: ShadowRoot): void {
46
+ injectStyles(shadowRoot);
47
+
48
+ // Overlay — full-screen backdrop
49
+ const overlay = document.createElement('div');
50
+ overlay.className = 'om-overlay';
51
+ overlay.dataset['onboardme'] = 'overlay';
52
+
53
+ // Card
54
+ const modal = document.createElement('div');
55
+ modal.className = 'om-modal';
56
+ modal.setAttribute('role', 'dialog');
57
+ modal.setAttribute('aria-modal', 'true');
58
+ modal.setAttribute('aria-labelledby', 'om-modal-title');
59
+
60
+ // Title
61
+ const title = document.createElement('h2');
62
+ title.id = 'om-modal-title';
63
+ title.className = 'om-modal__title';
64
+ title.textContent = step.title;
65
+
66
+ // Body
67
+ const body = document.createElement('p');
68
+ body.className = 'om-modal__body';
69
+ body.textContent = step.body;
70
+
71
+ // Actions row
72
+ const actions = document.createElement('div');
73
+ actions.className = 'om-modal__actions';
74
+
75
+ // Primary CTA
76
+ const primaryCta = document.createElement('button');
77
+ primaryCta.className = 'om-btn-primary';
78
+ primaryCta.type = 'button';
79
+ primaryCta.textContent = step.primaryCta ?? 'Get started';
80
+ primaryCta.dataset['onboardme'] = 'primary-cta';
81
+
82
+ actions.appendChild(primaryCta);
83
+
84
+ // Skip link — hidden when dismissible is explicitly false
85
+ if (step.dismissible !== false) {
86
+ const skipBtn = document.createElement('button');
87
+ skipBtn.className = 'om-btn-skip';
88
+ skipBtn.type = 'button';
89
+ skipBtn.textContent = step.secondaryCta ?? 'Skip for now';
90
+ skipBtn.dataset['onboardme'] = 'skip-cta';
91
+ actions.appendChild(skipBtn);
92
+ }
93
+
94
+ modal.appendChild(title);
95
+ modal.appendChild(body);
96
+ modal.appendChild(actions);
97
+ overlay.appendChild(modal);
98
+ shadowRoot.appendChild(overlay);
99
+ }
100
+
101
+ /**
102
+ * Removes the modal overlay from the shadow root and cleans up the keyboard
103
+ * listener. Does NOT clear the session flag — modal cannot reappear within
104
+ * the same browser session.
105
+ */
106
+ export function hideModal(shadowRoot: ShadowRoot): void {
107
+ const overlay = shadowRoot.querySelector('[data-onboardme="overlay"]');
108
+ if (overlay) {
109
+ shadowRoot.removeChild(overlay);
110
+ }
111
+
112
+ const keyHandler = _keyHandlers.get(shadowRoot);
113
+ if (keyHandler) {
114
+ document.removeEventListener('keydown', keyHandler);
115
+ _keyHandlers.delete(shadowRoot);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Shows the modal for a new user.
121
+ *
122
+ * Session guard: if the modal has already been shown in this browser session
123
+ * (sessionStorage flag set), returns immediately without rendering.
124
+ *
125
+ * On first show:
126
+ * - Sets the session flag so subsequent calls in the same tab are no-ops
127
+ * - Calls renderModal() to build the DOM
128
+ * - Wires Primary CTA and Skip button clicks to hideModal()
129
+ * - Adds a keydown listener for Escape → hideModal()
130
+ * - Moves focus to the primary CTA (accessibility)
131
+ */
132
+ export function showModal(step: FlowStep, shadowRoot: ShadowRoot, productId: string, flowId: string): void {
133
+ // Session guard
134
+ if (sessionStorage.getItem(sessionKey(productId)) !== null) {
135
+ return;
136
+ }
137
+ sessionStorage.setItem(sessionKey(productId), '1');
138
+
139
+ // Enqueue flow lifecycle + step view events
140
+ pushEvent('flow_started', { flowId });
141
+ pushEvent('step_viewed', { flowId, stepId: step.id, stepIndex: 0 });
142
+
143
+ renderModal(step, shadowRoot);
144
+
145
+ // Wire Primary CTA → step_completed + hideModal
146
+ const primaryCta = shadowRoot.querySelector<HTMLButtonElement>('[data-onboardme="primary-cta"]');
147
+ primaryCta?.addEventListener('click', () => {
148
+ pushEvent('step_completed', { flowId, stepId: step.id });
149
+ hideModal(shadowRoot);
150
+ });
151
+
152
+ // Wire Skip → step_skipped + hideModal
153
+ const skipBtn = shadowRoot.querySelector<HTMLButtonElement>('[data-onboardme="skip-cta"]');
154
+ skipBtn?.addEventListener('click', () => {
155
+ pushEvent('step_skipped', { flowId, stepId: step.id });
156
+ hideModal(shadowRoot);
157
+ });
158
+
159
+ // Keyboard: Escape always closes, regardless of dismissible flag
160
+ const keyHandler = (e: KeyboardEvent) => {
161
+ if (e.key === 'Escape') {
162
+ pushEvent('step_skipped', { flowId, stepId: step.id });
163
+ hideModal(shadowRoot);
164
+ }
165
+ };
166
+ _keyHandlers.set(shadowRoot, keyHandler);
167
+ document.addEventListener('keydown', keyHandler);
168
+
169
+ // Move focus to primary CTA for accessibility
170
+ primaryCta?.focus();
171
+ }
@@ -0,0 +1,30 @@
1
+ const ROOT_ID = 'onboardme-root';
2
+
3
+ /**
4
+ * Returns the Shadow DOM root that all OnboardMe components mount into.
5
+ *
6
+ * On first call: creates <div id="onboardme-root">, attaches an open
7
+ * Shadow DOM, injects a placeholder <style> element, and appends to body.
8
+ *
9
+ * On subsequent calls: returns the existing shadow root without creating
10
+ * a duplicate element.
11
+ */
12
+ export function getShadowRoot(): ShadowRoot {
13
+ const existing = document.getElementById(ROOT_ID);
14
+ if (existing?.shadowRoot) {
15
+ return existing.shadowRoot;
16
+ }
17
+
18
+ const host = document.createElement('div');
19
+ host.id = ROOT_ID;
20
+ document.body.appendChild(host);
21
+
22
+ const shadow = host.attachShadow({ mode: 'open' });
23
+
24
+ // Placeholder <style> — populated by component modules (Day 2+)
25
+ const style = document.createElement('style');
26
+ style.dataset['onboardme'] = 'styles';
27
+ shadow.appendChild(style);
28
+
29
+ return shadow;
30
+ }
@@ -0,0 +1,39 @@
1
+ import type { OnboardingEvent } from '@onboardme/types';
2
+ import { logger } from '../utils/logger.js';
3
+
4
+ const TIMEOUT_MS = 10_000;
5
+
6
+ /**
7
+ * Sends a batch of events to the configured endpoint.
8
+ * Never throws — always resolves to a boolean.
9
+ * Returns true on 2xx, false on any error (network, timeout, 4xx, 5xx).
10
+ * On failure the caller is responsible for retaining the queue.
11
+ */
12
+ export async function postEvents(
13
+ endpoint: string,
14
+ events: OnboardingEvent[],
15
+ ): Promise<boolean> {
16
+ const controller = new AbortController();
17
+ const timerId = setTimeout(() => controller.abort(), TIMEOUT_MS);
18
+
19
+ try {
20
+ const res = await fetch(endpoint, {
21
+ method: 'POST',
22
+ headers: { 'Content-Type': 'application/json' },
23
+ body: JSON.stringify({ events }),
24
+ signal: controller.signal,
25
+ });
26
+
27
+ if (!res.ok) {
28
+ logger.warn(`event flush failed — server responded ${res.status}`);
29
+ return false;
30
+ }
31
+
32
+ return true;
33
+ } catch (err) {
34
+ logger.warn(`event flush failed — ${(err as Error).message}`);
35
+ return false;
36
+ } finally {
37
+ clearTimeout(timerId);
38
+ }
39
+ }