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,301 @@
1
+ import type { OnboardMeConfig, FlowConfig, FlowStep } from '@onboardme/types';
2
+ import { logger } from '../utils/logger.js';
3
+ import { validateConfig } from './config.js';
4
+ import { detectUser } from '../detection/user-detection.js';
5
+ import { getAnonymousId } from '../utils/fingerprint.js';
6
+ import { getShadowRoot } from '../components/shadow-host.js';
7
+ import { showModal } from '../components/modal.js';
8
+ import { renderChecklist } from '../components/checklist.js';
9
+ import { showCelebration } from '../components/celebration.js';
10
+ import { loadProgress } from '../storage/progress-tracker.js';
11
+ import { configureBatcher, attachFlushListeners, pushEvent } from './event-batcher.js';
12
+ import { collectContext } from '../auto-generate/context-collector.js';
13
+ import { fetchGeneratedFlows, mergeFlows } from '../auto-generate/flow-generator-client.js';
14
+ import { fetchFlowsWithFallback, startFlowsPolling, stopFlowsPolling } from './api-flows.js';
15
+ import { collectAndSendSnapshot } from '../snapshot/sender.js';
16
+ import { isV2FlowConfig } from '../v2/types.js';
17
+ import { renderV2Flow, type V2RenderHandle } from '../v2/renderer.js';
18
+
19
+ export interface SDKState {
20
+ config: OnboardMeConfig;
21
+ anonymousId: string;
22
+ showOnboarding: boolean;
23
+ }
24
+
25
+ // Module-level initialised guard — prevents duplicate modals if the host app
26
+ // calls init() more than once (belt-and-suspenders alongside index.ts guard).
27
+ let _initialised = false;
28
+ let _config: OnboardMeConfig | null = null;
29
+
30
+ // Architecture v2 — Phase F. Active v2 render handles, keyed by flow id.
31
+ // Tracked at module scope so polling refreshes can tear down stale renders
32
+ // before mounting the new ones.
33
+ const _v2Handles = new Map<string, V2RenderHandle>();
34
+
35
+ function _teardownV2Renders(): void {
36
+ for (const handle of _v2Handles.values()) handle.destroy();
37
+ _v2Handles.clear();
38
+ }
39
+
40
+ /**
41
+ * Core orchestrator — runs once on init().
42
+ * Returns the initialised SDK state, or null if config is invalid.
43
+ *
44
+ * Sequence:
45
+ * 1. Guard against double-init (warn, return null)
46
+ * 2. Validate config (warn, never throw)
47
+ * 3. Generate / retrieve anonymousId
48
+ * 4. Run user detection
49
+ * 5. Set showOnboarding flag
50
+ * 6. If apiFlows is configured: fetch flows from API + start polling
51
+ * 7. If new user: schedule showModal via setTimeout(fn, 0) — defers until
52
+ * after init() returns so initialisation stays synchronous
53
+ * 8. Warn if no flows are defined
54
+ */
55
+ export function runSDK(raw: unknown): SDKState | null {
56
+ if (_initialised) {
57
+ logger.warn('OnboardMe already initialised — ignoring duplicate init() call.');
58
+ return null;
59
+ }
60
+
61
+ const config = validateConfig(raw);
62
+ if (!config) return null;
63
+
64
+ _initialised = true;
65
+ _config = config;
66
+
67
+ logger.log(`initialised for product: ${config.productId}`);
68
+
69
+ const anonymousId = getAnonymousId(config.productId);
70
+ logger.log(`anonymous ID: ${anonymousId}`);
71
+
72
+ // Wire event batcher — must happen before any component emits events
73
+ configureBatcher(config.eventsEndpoint ?? 'http://localhost:3001/events', anonymousId);
74
+ attachFlushListeners();
75
+
76
+ const detection = detectUser(config);
77
+ const showOnboarding = detection.isNew;
78
+
79
+ if (detection.isNew) {
80
+ logger.log(`new user detected (reason: ${detection.reason})`);
81
+ } else {
82
+ logger.log(`returning user, skipping onboarding`);
83
+ }
84
+
85
+ // Fetch flows from API if configured
86
+ if (config.apiFlows) {
87
+ void _bootstrapApiFlows(config, showOnboarding);
88
+ // Architecture v2 / Phase B — opportunistic DOM snapshot.
89
+ // Sends one snapshot per session per page so OnboardMe can build flows
90
+ // grounded in the host app's actual UI. Fire-and-forget; never blocks.
91
+ void _bootstrapSnapshot(config);
92
+ } else if (config.flows.length === 0) {
93
+ logger.warn('no flows defined in config — nothing to show');
94
+ } else {
95
+ _renderFlows(config.flows, config, showOnboarding);
96
+ }
97
+
98
+ if (config.autoGenerate) {
99
+ void _bootstrapAutoGenerate(config, showOnboarding);
100
+ }
101
+
102
+ return { config, anonymousId, showOnboarding };
103
+ }
104
+
105
+ /**
106
+ * Sends a single DOM snapshot for the current page if apiFlows is configured.
107
+ * Debounced server-side AND client-side (sessionStorage). Never throws.
108
+ * Defers to setTimeout(0) so init() returns synchronously and the host
109
+ * page's first paint isn't delayed by snapshot collection.
110
+ */
111
+ async function _bootstrapSnapshot(config: OnboardMeConfig): Promise<void> {
112
+ if (!config.apiFlows) return;
113
+ // Defer one tick so the SDK doesn't compete with first paint.
114
+ await new Promise((resolve) => setTimeout(resolve, 0));
115
+ await collectAndSendSnapshot(
116
+ config.productId,
117
+ config.apiFlows.endpoint,
118
+ config.apiFlows.apiKey,
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Renders the first modal step (for new users) and the first checklist flow
124
+ * from the given flows array. Shared by the sync init path and the async
125
+ * auto-generate path so both use identical rendering logic.
126
+ */
127
+ export function _renderFlows(
128
+ flows: FlowConfig[],
129
+ config: OnboardMeConfig,
130
+ showOnboarding: boolean,
131
+ ): void {
132
+ if (flows.length === 0) return;
133
+
134
+ // Architecture v2 — Phase F. Partition flows by schema. v2 flows go through
135
+ // the new renderer (tooltip / modal / highlight matching the v2 API schema);
136
+ // legacy flows continue down the original modal/checklist path below.
137
+ const legacyFlows: FlowConfig[] = [];
138
+ for (const flow of flows) {
139
+ if (isV2FlowConfig(flow.config ?? flow)) {
140
+ // Tear down any previous render of this same flow (e.g. on a polling
141
+ // refresh) before mounting the new version.
142
+ _v2Handles.get(flow.id)?.destroy();
143
+ _v2Handles.delete(flow.id);
144
+
145
+ // The v2 API stores steps under flow.config.steps; some legacy code
146
+ // paths nest them at flow.steps. Normalise here so the renderer
147
+ // accepts both shapes.
148
+ const v2Config = isV2FlowConfig(flow.config) ? flow.config : (flow as unknown as { steps: unknown }).steps
149
+ ? { steps: (flow as unknown as { steps: unknown[] }).steps }
150
+ : null;
151
+ if (!v2Config) continue;
152
+
153
+ // Defer to a microtask so init() stays synchronous and host first paint
154
+ // isn't delayed by querying the DOM for tooltip targets.
155
+ setTimeout(() => {
156
+ const handle = renderV2Flow(v2Config as { steps: never[] }, getShadowRoot());
157
+ if (handle) _v2Handles.set(flow.id, handle);
158
+ }, 0);
159
+ } else {
160
+ legacyFlows.push(flow);
161
+ }
162
+ }
163
+
164
+ if (legacyFlows.length === 0) return;
165
+ flows = legacyFlows;
166
+
167
+ if (showOnboarding) {
168
+ interface ModalCandidate { step: FlowStep; flowId: string }
169
+ const candidates: ModalCandidate[] = flows
170
+ .flatMap((f) => f.steps.filter((s) => s.type === 'modal').map((s) => ({ step: s, flowId: f.id })))
171
+ .sort((a, b) => a.step.order - b.step.order);
172
+ const first = candidates[0];
173
+ if (first) {
174
+ setTimeout(() => {
175
+ showModal(first.step, getShadowRoot(), config.productId, first.flowId);
176
+ }, 0);
177
+ } else {
178
+ logger.log('no modal step found — skipping welcome modal');
179
+ }
180
+ }
181
+
182
+ for (const flow of flows) {
183
+ if (!flow.steps.some((s) => s.type === 'checklist')) continue;
184
+ const progress = loadProgress(config.productId, flow.id);
185
+ setTimeout(() => {
186
+ renderChecklist(flow, progress, getShadowRoot(), config.debug ?? false, config.productId, flow.id);
187
+ }, 0);
188
+ break; // only the first checklist flow
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Async bootstrap for fetching flows from the OnboardMe API. Runs
194
+ * fire-and-forget after the synchronous init path. Only active when
195
+ * config.apiFlows is set. Fetches initial flows and starts polling
196
+ * for updates every 60 seconds.
197
+ */
198
+ export async function _bootstrapApiFlows(
199
+ config: OnboardMeConfig,
200
+ showOnboarding: boolean,
201
+ ): Promise<void> {
202
+ if (!config.apiFlows) return;
203
+
204
+ const cacheKey = `onboardme_flows_${config.productId}`;
205
+ const response = await fetchFlowsWithFallback(
206
+ config.apiFlows.endpoint,
207
+ config.apiFlows.apiKey,
208
+ cacheKey,
209
+ );
210
+
211
+ if (response.flows.length === 0) {
212
+ logger.warn('no flows from API and no cache available');
213
+ return;
214
+ }
215
+
216
+ // Update module config with API flows
217
+ if (_config) {
218
+ _config = { ..._config, flows: response.flows };
219
+ }
220
+
221
+ _renderFlows(response.flows, config, showOnboarding);
222
+
223
+ // Start polling for updates — uses checksumHash to avoid unnecessary re-renders
224
+ startFlowsPolling(
225
+ config.apiFlows.endpoint,
226
+ config.apiFlows.apiKey,
227
+ cacheKey,
228
+ (flows: FlowConfig[]) => {
229
+ // Update config with new flows
230
+ if (_config) {
231
+ _config = { ..._config, flows };
232
+ }
233
+ // Re-render new flows
234
+ _renderFlows(flows, config, showOnboarding);
235
+ },
236
+ );
237
+ }
238
+
239
+ /**
240
+ * Async bootstrap for AI-generated flows. Runs fire-and-forget after the
241
+ * synchronous init path. Only active when config.autoGenerate is set.
242
+ * Collects DOM context, fetches generated flows, merges with manual flows,
243
+ * and renders any novel flows that are not already in the manual config.
244
+ */
245
+ export async function _bootstrapAutoGenerate(
246
+ config: OnboardMeConfig,
247
+ showOnboarding: boolean,
248
+ ): Promise<void> {
249
+ if (!config.autoGenerate) return;
250
+
251
+ const context = collectContext();
252
+ const generated = await fetchGeneratedFlows(
253
+ config.autoGenerate.endpoint,
254
+ config.productId,
255
+ context,
256
+ config.autoGenerate.cacheTtlMs,
257
+ );
258
+
259
+ if (generated.length === 0) return;
260
+
261
+ const existingIds = new Set(config.flows.map((f) => f.id));
262
+ const novel = generated.filter((f) => !existingIds.has(f.id));
263
+
264
+ if (novel.length === 0) return;
265
+
266
+ // Update module config so checkCompletionGoal sees generated flows
267
+ if (_config) {
268
+ _config = { ..._config, flows: mergeFlows(_config.flows, generated) };
269
+ }
270
+
271
+ _renderFlows(novel, config, showOnboarding);
272
+ }
273
+
274
+ /**
275
+ * Checks whether a tracked event name matches any active flow's completionGoal.
276
+ * If it does and the goal has not already fired, enqueues flow_completed +
277
+ * goal_reached events and shows the celebration banner.
278
+ * Called by index.ts track() on every OnboardMe.track() invocation.
279
+ */
280
+ export function checkCompletionGoal(eventName: string): void {
281
+ if (!_config) return;
282
+
283
+ for (const flow of _config.flows) {
284
+ if (eventName !== flow.completionGoal) continue;
285
+
286
+ const key = `onboardme_flow_done_${_config.productId}_${flow.id}`;
287
+ if (localStorage.getItem(key)) continue; // already fired — prevent duplicate
288
+
289
+ localStorage.setItem(key, '1');
290
+ pushEvent('flow_completed', { flowId: flow.id });
291
+ pushEvent('goal_reached', { flowId: flow.id });
292
+ showCelebration(getShadowRoot());
293
+ }
294
+ }
295
+
296
+ /** Resets the initialised flag — only used in tests. */
297
+ export function _resetSDK(): void {
298
+ _initialised = false;
299
+ _config = null;
300
+ stopFlowsPolling();
301
+ }
@@ -0,0 +1,55 @@
1
+ import type { OnboardMeConfig } from '@onboardme/types';
2
+
3
+ export type DetectionResult =
4
+ | { isNew: true; reason: 'first_visit' | 'new_account' }
5
+ | { isNew: false; reason: 'returning' };
6
+
7
+ const NEW_ACCOUNT_THRESHOLD_MS = 48 * 60 * 60 * 1000; // 48 hours
8
+
9
+ /**
10
+ * Three-layer first-time user detection, checked in order:
11
+ *
12
+ * Layer 1 — localStorage flag: if onboardme_seen_{productId} exists → returning user.
13
+ * Layer 2 — Account age: if config.user.createdAt < 48h ago → new user.
14
+ * Layer 3 — Fallback: no info → treat as new (better to show once too many than miss a new user).
15
+ *
16
+ * On a positive new-user result, the seen flag is written to localStorage
17
+ * so the next visit is correctly identified as returning.
18
+ */
19
+ export function detectUser(config: OnboardMeConfig): DetectionResult {
20
+ const seenKey = `onboardme_seen_${config.productId}`;
21
+
22
+ // Layer 1 — localStorage flag
23
+ try {
24
+ if (localStorage.getItem(seenKey) !== null) {
25
+ return { isNew: false, reason: 'returning' };
26
+ }
27
+ } catch {
28
+ // localStorage unavailable — fall through to next layer
29
+ }
30
+
31
+ // Layer 2 — Account age
32
+ const createdAt = config.user?.createdAt;
33
+ if (createdAt) {
34
+ const ageMs = Date.now() - new Date(createdAt).getTime();
35
+ if (ageMs >= NEW_ACCOUNT_THRESHOLD_MS) {
36
+ // Old account, probably cleared their cache — treat as returning
37
+ return { isNew: false, reason: 'returning' };
38
+ }
39
+ // Account is fresh — new user
40
+ _markSeen(seenKey);
41
+ return { isNew: true, reason: 'new_account' };
42
+ }
43
+
44
+ // Layer 3 — Fallback: no information, assume new
45
+ _markSeen(seenKey);
46
+ return { isNew: true, reason: 'first_visit' };
47
+ }
48
+
49
+ function _markSeen(key: string): void {
50
+ try {
51
+ localStorage.setItem(key, '1');
52
+ } catch {
53
+ // Silently ignore if localStorage is unavailable
54
+ }
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { OnboardMeConfig } from '@onboardme/types';
2
+ import { setDebug, logger } from './utils/logger.js';
3
+ import { runSDK, checkCompletionGoal } from './core/sdk.js';
4
+ import { setBatcherUserId, pushEvent } from './core/event-batcher.js';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Module state
8
+ // ---------------------------------------------------------------------------
9
+
10
+ let _initialised = false;
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Public API
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const OnboardMe = {
17
+ init(config: OnboardMeConfig): void {
18
+ if (_initialised) {
19
+ logger.warn('init() called more than once — ignoring duplicate call.');
20
+ return;
21
+ }
22
+
23
+ // Enable debug logging before any further output
24
+ if (config?.debug) setDebug(true);
25
+
26
+ const state = runSDK(config);
27
+ if (!state) return;
28
+
29
+ _initialised = true;
30
+
31
+ // Replay any calls that were queued before the script finished loading
32
+ _replayQueue();
33
+ },
34
+
35
+ identify(userId: string, traits?: Record<string, unknown>): void {
36
+ if (!_initialised) {
37
+ logger.warn('identify() called before init() — call will be ignored.');
38
+ return;
39
+ }
40
+ setBatcherUserId(userId);
41
+ logger.log(`identify: ${userId}`, traits);
42
+ },
43
+
44
+ track(eventName: string, properties?: Record<string, unknown>): void {
45
+ if (!_initialised) {
46
+ logger.warn('track() called before init() — call will be ignored.');
47
+ return;
48
+ }
49
+ pushEvent('step_action_taken', { properties: { eventName, ...(properties ?? {}) } });
50
+ checkCompletionGoal(eventName);
51
+ logger.log(`track: ${eventName}`, properties);
52
+ },
53
+ };
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Async snippet queue replay
57
+ // Product teams paste a stub into their HTML before the SDK loads.
58
+ // The stub queues calls in window.OnboardMe._q.
59
+ // When the real SDK loads, we replay everything that was buffered.
60
+ // ---------------------------------------------------------------------------
61
+
62
+ type QueueEntry = [string, IArguments | unknown[]];
63
+
64
+ interface OnboardMeStub {
65
+ _q?: QueueEntry[];
66
+ }
67
+
68
+ function _replayQueue(): void {
69
+ const stub = (window as unknown as { OnboardMe?: OnboardMeStub }).OnboardMe;
70
+ const queue = stub?._q;
71
+ if (!Array.isArray(queue) || queue.length === 0) return;
72
+
73
+ logger.log(`replaying ${queue.length} queued call(s)`);
74
+
75
+ for (const [method, args] of queue) {
76
+ if (method === 'identify') {
77
+ OnboardMe.identify(args[0] as string, args[1] as Record<string, unknown>);
78
+ } else if (method === 'track') {
79
+ OnboardMe.track(args[0] as string, args[1] as Record<string, unknown>);
80
+ }
81
+ }
82
+
83
+ // Clear the queue so replayed calls are not re-run
84
+ if (stub) stub._q = [];
85
+ }
86
+
87
+ // Replace the stub on window with the real SDK
88
+ (window as unknown as { OnboardMe: typeof OnboardMe }).OnboardMe = OnboardMe;
89
+
90
+ export default OnboardMe;
91
+
92
+ /** Resets the index-level initialised flag — only used in tests. */
93
+ export function _resetIndex(): void {
94
+ _initialised = false;
95
+ }
@@ -0,0 +1,193 @@
1
+ // DOM collector (Architecture v2 — Phase B).
2
+ //
3
+ // Walks the host page's DOM and extracts a normalised, AI-readable summary
4
+ // of the UI: headings, buttons, links, form inputs, navigation, images.
5
+ // Output is the SnapshotPayload the API expects at POST /v1/code-sources/snapshot.
6
+ //
7
+ // Constraints:
8
+ // - Read-only — never mutates the host page DOM
9
+ // - Bounded — caps element count and text length per element
10
+ // - Resilient — wrapped in try/catch where a malformed DOM might throw
11
+
12
+ export type SnapshotRole =
13
+ | 'heading'
14
+ | 'button'
15
+ | 'link'
16
+ | 'input'
17
+ | 'form'
18
+ | 'nav'
19
+ | 'image'
20
+ | 'other'
21
+
22
+ export interface SnapshotElement {
23
+ selector: string
24
+ role: SnapshotRole
25
+ text?: string
26
+ attributes?: Record<string, string>
27
+ }
28
+
29
+ export interface SnapshotPayload {
30
+ pageUrl: string
31
+ pageTitle: string
32
+ collectedAt: number
33
+ elements: SnapshotElement[]
34
+ meta?: {
35
+ userAgent?: string
36
+ viewport?: { width: number; height: number }
37
+ }
38
+ }
39
+
40
+ const MAX_ELEMENTS = 500
41
+ const MAX_TEXT_LEN = 200
42
+
43
+ // Roles in the order we collect them — earlier roles win when an element
44
+ // could match multiple categories (e.g. <a role="button"> is a button).
45
+ const COLLECTORS: Array<{ selector: string; role: SnapshotRole }> = [
46
+ { selector: 'h1, h2, h3, h4, h5, h6', role: 'heading' },
47
+ { selector: 'button, [role="button"]', role: 'button' },
48
+ { selector: 'a[href]', role: 'link' },
49
+ { selector: 'input, textarea, select', role: 'input' },
50
+ { selector: 'form', role: 'form' },
51
+ { selector: 'nav, [role="navigation"]', role: 'nav' },
52
+ { selector: 'img[alt]', role: 'image' },
53
+ ]
54
+
55
+ const ATTRIBUTES_BY_ROLE: Record<SnapshotRole, string[]> = {
56
+ heading: [],
57
+ button: ['type', 'name', 'aria-label', 'data-testid'],
58
+ link: ['href', 'aria-label', 'data-testid'],
59
+ input: ['type', 'name', 'placeholder', 'aria-label', 'data-testid'],
60
+ form: ['name', 'action', 'method'],
61
+ nav: ['aria-label'],
62
+ image: ['alt', 'src'],
63
+ other: [],
64
+ }
65
+
66
+ export function collectSnapshot(doc: Document = document, win: Window = window): SnapshotPayload {
67
+ const seen = new Set<Element>()
68
+ const elements: SnapshotElement[] = []
69
+
70
+ for (const { selector, role } of COLLECTORS) {
71
+ const nodes = safeQueryAll(doc, selector)
72
+ for (const node of nodes) {
73
+ if (seen.has(node)) continue
74
+ if (!isVisible(node, win)) continue
75
+ seen.add(node)
76
+
77
+ const el: SnapshotElement = {
78
+ selector: buildSelector(node),
79
+ role,
80
+ }
81
+ const text = extractText(node)
82
+ if (text) el.text = text
83
+ const attrs = extractAttributes(node, role)
84
+ if (Object.keys(attrs).length > 0) el.attributes = attrs
85
+
86
+ elements.push(el)
87
+ if (elements.length >= MAX_ELEMENTS) break
88
+ }
89
+ if (elements.length >= MAX_ELEMENTS) break
90
+ }
91
+
92
+ return {
93
+ pageUrl: win.location?.href ?? '',
94
+ pageTitle: doc.title ?? '',
95
+ collectedAt: Date.now(),
96
+ elements,
97
+ meta: {
98
+ userAgent: win.navigator?.userAgent,
99
+ viewport: {
100
+ width: win.innerWidth ?? 0,
101
+ height: win.innerHeight ?? 0,
102
+ },
103
+ },
104
+ }
105
+ }
106
+
107
+ // ─── Selector synthesis ──────────────────────────────────────────────────────
108
+ // Prefers stable identifiers in this order:
109
+ // 1. data-testid attribute
110
+ // 2. id attribute (if it doesn't look auto-generated)
111
+ // 3. tag + nth-of-type fallback (always present)
112
+ // Auto-generated id heuristic: contains digits beyond a small constant or
113
+ // matches react/__-style prefixes — these change across renders.
114
+
115
+ export function buildSelector(el: Element): string {
116
+ const testId = el.getAttribute('data-testid')
117
+ if (testId) return `[data-testid="${cssEscape(testId)}"]`
118
+
119
+ const id = el.getAttribute('id')
120
+ if (id && !looksAutoGenerated(id)) return `#${cssEscape(id)}`
121
+
122
+ const tag = el.tagName.toLowerCase()
123
+
124
+ // nth-of-type relative to siblings of same tag — gives a unique selector
125
+ // even when no IDs/classes are available.
126
+ const parent = el.parentElement
127
+ if (!parent) return tag
128
+
129
+ const siblings = Array.from(parent.children).filter((c) => c.tagName === el.tagName)
130
+ if (siblings.length === 1) return tag
131
+ const idx = siblings.indexOf(el) + 1
132
+ return `${tag}:nth-of-type(${idx})`
133
+ }
134
+
135
+ function looksAutoGenerated(id: string): boolean {
136
+ // React/Vue framework patterns: __, react-, _stable_, long digit runs
137
+ if (id.startsWith('__') || id.startsWith('_react')) return true
138
+ const digitRun = id.match(/\d{6,}/)
139
+ return digitRun !== null
140
+ }
141
+
142
+ // CSS.escape isn't available everywhere — a minimal-shim covers identifier chars.
143
+ function cssEscape(value: string): string {
144
+ return value.replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`)
145
+ }
146
+
147
+ // ─── Text extraction ─────────────────────────────────────────────────────────
148
+
149
+ function extractText(el: Element): string {
150
+ const raw = (el as HTMLElement).innerText ?? el.textContent ?? ''
151
+ const trimmed = raw.replace(/\s+/g, ' ').trim()
152
+ if (trimmed.length === 0) return ''
153
+ if (trimmed.length <= MAX_TEXT_LEN) return trimmed
154
+ return trimmed.slice(0, MAX_TEXT_LEN - 1) + '…'
155
+ }
156
+
157
+ // ─── Attribute extraction ────────────────────────────────────────────────────
158
+
159
+ function extractAttributes(el: Element, role: SnapshotRole): Record<string, string> {
160
+ const out: Record<string, string> = {}
161
+ for (const name of ATTRIBUTES_BY_ROLE[role]) {
162
+ const val = el.getAttribute(name)
163
+ if (val !== null && val.length > 0) out[name] = val.slice(0, 200)
164
+ }
165
+ return out
166
+ }
167
+
168
+ // ─── Visibility check ────────────────────────────────────────────────────────
169
+ // Skip hidden elements — they're not part of the actual user-visible UI.
170
+
171
+ function isVisible(el: Element, win: Window): boolean {
172
+ // jsdom's getComputedStyle returns sensible defaults; in production browsers
173
+ // it's authoritative. Either way, treat unknowns as visible (safer for
174
+ // capture; if we miss showing something, the user can always re-collect).
175
+ try {
176
+ const style = win.getComputedStyle?.(el as HTMLElement)
177
+ if (!style) return true
178
+ if (style.display === 'none') return false
179
+ if (style.visibility === 'hidden') return false
180
+ if (style.opacity === '0') return false
181
+ } catch {
182
+ return true
183
+ }
184
+ return true
185
+ }
186
+
187
+ function safeQueryAll(doc: Document, selector: string): Element[] {
188
+ try {
189
+ return Array.from(doc.querySelectorAll(selector))
190
+ } catch {
191
+ return []
192
+ }
193
+ }