@eventop/sdk 1.2.11 → 1.2.13

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.
package/dist/index.cjs CHANGED
@@ -29,18 +29,41 @@ function useFeatureScope() {
29
29
  /**
30
30
  * FeatureRegistry
31
31
  *
32
- * The live map of everything currently mounted in the React tree.
32
+ * The live map of everything registered in the React tree — both mounted
33
+ * (full entries) and unmounted (ghost entries).
33
34
  *
34
- * Features — registered by EventopTarget
35
- * Flow steps registered by EventopStep, attached to a feature by id
35
+ * ┌─────────────────────────────────────────────────────────────────────┐
36
+ * │ Full entry component is currently mounted │
37
+ * │ { id, name, description, route, selector, ... } │
38
+ * │ │
39
+ * │ Ghost entry — component has unmounted (navigated away) │
40
+ * │ { id, name, description, route, selector: null, _ghost: true } │
41
+ * └─────────────────────────────────────────────────────────────────────┘
36
42
  *
37
- * Both features and steps can live anywhere in the component tree.
38
- * They don't need to be co-located. An EventopStep just needs to know
39
- * which feature id it belongs to.
43
+ * Why ghosts?
44
+ *
45
+ * The AI system prompt is built from the registry snapshot. Without ghosts,
46
+ * features on other pages don't exist in the snapshot when the user is
47
+ * elsewhere — the AI can't pick them, so cross-page tours never start.
48
+ *
49
+ * With ghosts, every feature the app has ever rendered stays in the
50
+ * snapshot permanently. The AI always has the full picture. The tour
51
+ * runner already resolves selectors lazily (after navigation), so a
52
+ * null selector on a ghost is fine — it gets filled in at show-time
53
+ * once the component mounts on the target page.
54
+ *
55
+ * Lifecycle:
56
+ *
57
+ * EventopTarget mounts → registerFeature() → full entry
58
+ * EventopTarget unmounts → unregisterFeature() → ghost entry (metadata kept)
59
+ * EventopTarget remounts → registerFeature() → full entry again (selector restored)
60
+ *
61
+ * Flow steps follow the same pattern — they're pruned on unmount but the
62
+ * parent feature ghost keeps the feature visible to the AI.
40
63
  *
41
64
  * Nested steps:
42
- * Steps can themselves have children steps (sub-steps) by passing
43
- * a parentStep prop to EventopStep. This lets you model flows like:
65
+ * Steps can have children steps (sub-steps) by passing a parentStep prop.
66
+ * This lets you model flows like:
44
67
  *
45
68
  * Feature: "Create styled text"
46
69
  * Step 0: Click Add Text
@@ -49,17 +72,10 @@ function useFeatureScope() {
49
72
  * Step 1.1: Open font picker ← parentStep: 1, index: 1
50
73
  * Step 1.2: Choose a font ← parentStep: 1, index: 2
51
74
  * Step 2: Click Done
52
- *
53
- * In practice most flows are flat (index 0, 1, 2, 3...) but the
54
- * registry supports nested depth for complex interactions.
55
- *
56
- * Route awareness:
57
- * Features can declare the pathname they live on via `route`.
58
- * The snapshot() includes this so the SDK core can navigate
59
- * automatically when a tour step targets a feature on a different page.
60
75
  */
61
76
  function createFeatureRegistry() {
62
77
  // Map<featureId, featureData>
78
+ // Values are either full entries or ghost entries (_ghost: true)
63
79
  const features = new Map();
64
80
  // Map<featureId, Map<stepKey, stepData>>
65
81
  const flowSteps = new Map();
@@ -71,20 +87,37 @@ function createFeatureRegistry() {
71
87
  // ── Feature registration ─────────────────────────────────────────────────
72
88
 
73
89
  function registerFeature(feature) {
90
+ // Restore a ghost or create a fresh full entry.
91
+ // Always overwrite so a remount gets the latest selector.
74
92
  features.set(feature.id, {
75
93
  ...feature,
94
+ _ghost: false,
76
95
  _registeredAt: Date.now()
77
96
  });
78
97
  notify();
79
98
  }
80
99
  function unregisterFeature(id) {
100
+ if (!features.has(id)) return;
101
+
102
+ // Downgrade to ghost — keep all metadata, null out the live selector.
103
+ // Flow steps are pruned separately (their DOM elements are gone too).
104
+ const feature = features.get(id);
81
105
  features.set(id, {
82
- ...existing,
106
+ id: feature.id,
107
+ name: feature.name,
108
+ description: feature.description,
109
+ route: feature.route || null,
110
+ navigate: feature.navigate || null,
111
+ navigateWaitFor: feature.navigateWaitFor || null,
112
+ _registeredAt: feature._registeredAt,
83
113
  selector: null,
84
114
  advanceOn: null,
85
115
  waitFor: null,
86
116
  _ghost: true
87
117
  });
118
+
119
+ // Prune flow steps — their selectors are dead too.
120
+ // They'll re-register when the component remounts on the target page.
88
121
  flowSteps.delete(id);
89
122
  notify();
90
123
  }
@@ -130,41 +163,62 @@ function createFeatureRegistry() {
130
163
  }
131
164
 
132
165
  /**
133
- * Returns the live feature array in the format the SDK core expects.
166
+ * Returns the full feature array both live and ghost entries.
134
167
  *
135
- * `route` is now included in every feature entry so the core can detect
136
- * when navigation is needed before showing a step.
168
+ * Ghost entries have selector: null. The SDK core handles this correctly:
169
+ * - The AI system prompt uses name/description/route (always present)
170
+ * - The tour runner resolves the selector lazily after navigation,
171
+ * at which point the component has remounted and re-registered
172
+ *
173
+ * screen.check() returns false for ghosts so the SDK knows navigation
174
+ * is needed before showing the step.
137
175
  */
138
176
  function snapshot() {
139
177
  return Array.from(features.values()).map(feature => {
140
178
  const flow = buildFlow(feature.id);
179
+ const isGhost = feature._ghost === true;
141
180
  return {
142
181
  id: feature.id,
143
182
  name: feature.name,
144
183
  description: feature.description,
145
- selector: feature.selector,
184
+ selector: feature.selector || null,
146
185
  route: feature.route || null,
147
186
  advanceOn: feature.advanceOn || null,
148
187
  waitFor: feature.waitFor || null,
188
+ _ghost: isGhost,
149
189
  ...(flow ? {
150
190
  flow
151
191
  } : {}),
152
192
  screen: {
153
193
  id: feature.id,
154
- check: () => features.has(feature.id),
194
+ // Ghost entries always fail the check SDK knows to navigate
195
+ check: () => {
196
+ var _features$get;
197
+ return ((_features$get = features.get(feature.id)) === null || _features$get === void 0 ? void 0 : _features$get._ghost) !== true;
198
+ },
155
199
  navigate: feature.navigate || null,
156
200
  waitFor: feature.navigateWaitFor || feature.selector || null
157
201
  }
158
202
  };
159
203
  });
160
204
  }
205
+
206
+ // ── Helpers ───────────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Returns true only for fully mounted (non-ghost) features.
210
+ */
211
+ function isRegistered(id) {
212
+ const f = features.get(id);
213
+ return !!f && !f._ghost;
214
+ }
161
215
  return {
162
216
  registerFeature,
163
217
  unregisterFeature,
164
218
  registerStep,
165
219
  unregisterStep,
166
220
  snapshot,
167
- isRegistered: id => features.has(id),
221
+ isRegistered,
168
222
  subscribe: fn => {
169
223
  listeners.add(fn);
170
224
  return () => listeners.delete(fn);
@@ -261,18 +315,28 @@ function EventopTarget({
261
315
  navigate,
262
316
  navigateWaitFor,
263
317
  advanceOn,
264
- waitFor,
265
- ...rest
318
+ waitFor
266
319
  }) {
267
320
  const registry = useRegistry();
268
- const ref = react.useRef(null);
321
+ const wrapperRef = react.useRef(null);
269
322
  const dataAttr = `data-evtp-${id}`;
270
323
  const selector = `[${dataAttr}]`;
271
324
  react.useEffect(() => {
325
+ var _wrapperRef$current;
272
326
  if (!id || !name) {
273
327
  console.warn('[Eventop] <EventopTarget> requires id and name props.');
274
328
  return;
275
329
  }
330
+
331
+ // Set the attribute directly on the first real child DOM element.
332
+ // This bypasses React's prop system entirely — works regardless of whether
333
+ // the wrapped component forwards unknown props.
334
+ const firstChild = (_wrapperRef$current = wrapperRef.current) === null || _wrapperRef$current === void 0 ? void 0 : _wrapperRef$current.firstElementChild;
335
+ if (firstChild) {
336
+ firstChild.setAttribute(dataAttr, '');
337
+ } else {
338
+ console.warn(`[Eventop] <EventopTarget id="${id}"> could not find a child DOM element to attach to. ` + `Make sure the wrapped component renders at least one DOM element.`);
339
+ }
276
340
  registry.registerFeature({
277
341
  id,
278
342
  name,
@@ -287,32 +351,24 @@ function EventopTarget({
287
351
  ...advanceOn
288
352
  } : null
289
353
  });
290
- return () => registry.unregisterFeature(id);
291
- }, [id, name, description, route]);
292
- const child = react.Children.only(children);
293
- let wrapped;
294
- try {
295
- wrapped = /*#__PURE__*/react.cloneElement(child, {
296
- [dataAttr]: '',
297
- ref: node => {
298
- ref.current = node;
299
- const originalRef = child.ref;
300
- if (typeof originalRef === 'function') originalRef(node);else if (originalRef && 'current' in originalRef) originalRef.current = node;
301
- }
302
- });
303
- } catch {
304
- wrapped = /*#__PURE__*/jsxRuntime.jsx("span", {
305
- [dataAttr]: '',
306
- ref: ref,
354
+ return () => {
355
+ var _wrapperRef$current2;
356
+ // Clean up the injected attribute on unmount
357
+ const el = (_wrapperRef$current2 = wrapperRef.current) === null || _wrapperRef$current2 === void 0 ? void 0 : _wrapperRef$current2.firstElementChild;
358
+ if (el) el.removeAttribute(dataAttr);
359
+ registry.unregisterFeature(id);
360
+ };
361
+ }, [id, name, description, route]); // eslint-disable-line react-hooks/exhaustive-deps
362
+
363
+ return /*#__PURE__*/jsxRuntime.jsx(EventopFeatureScopeContext.Provider, {
364
+ value: id,
365
+ children: /*#__PURE__*/jsxRuntime.jsx("span", {
366
+ ref: wrapperRef,
307
367
  style: {
308
368
  display: 'contents'
309
369
  },
310
- children: child
311
- });
312
- }
313
- return /*#__PURE__*/jsxRuntime.jsx(EventopFeatureScopeContext.Provider, {
314
- value: id,
315
- children: wrapped
370
+ children: react.Children.only(children)
371
+ })
316
372
  });
317
373
  }
318
374
 
@@ -327,7 +383,7 @@ function EventopStep({
327
383
  const registry = useRegistry();
328
384
  const featureScope = useFeatureScope();
329
385
  const featureId = feature || featureScope;
330
- const ref = react.useRef(null);
386
+ const wrapperRef = react.useRef(null);
331
387
  if (!featureId) {
332
388
  console.warn('[Eventop] <EventopStep> needs either a feature prop or an <EventopTarget> ancestor.');
333
389
  }
@@ -337,7 +393,14 @@ function EventopStep({
337
393
  const dataAttr = `data-evtp-step-${featureId}-${parentStep != null ? `${parentStep}-` : ''}${index}`;
338
394
  const selector = `[${dataAttr}]`;
339
395
  react.useEffect(() => {
396
+ var _wrapperRef$current;
340
397
  if (!featureId || index == null) return;
398
+
399
+ // Inject attribute directly onto the first real child DOM element
400
+ const firstChild = (_wrapperRef$current = wrapperRef.current) === null || _wrapperRef$current === void 0 ? void 0 : _wrapperRef$current.firstElementChild;
401
+ if (firstChild) {
402
+ firstChild.setAttribute(dataAttr, '');
403
+ }
341
404
  registry.registerStep(featureId, index, parentStep ?? null, {
342
405
  selector,
343
406
  waitFor: waitFor || null,
@@ -346,30 +409,21 @@ function EventopStep({
346
409
  ...advanceOn
347
410
  } : null
348
411
  });
349
- return () => registry.unregisterStep(featureId, index, parentStep ?? null);
350
- }, [featureId, index, parentStep]);
351
- const child = react.Children.only(children);
352
- let wrapped;
353
- try {
354
- wrapped = /*#__PURE__*/react.cloneElement(child, {
355
- [dataAttr]: '',
356
- ref: node => {
357
- ref.current = node;
358
- const originalRef = child.ref;
359
- if (typeof originalRef === 'function') originalRef(node);else if (originalRef && 'current' in originalRef) originalRef.current = node;
360
- }
361
- });
362
- } catch {
363
- wrapped = /*#__PURE__*/jsxRuntime.jsx("span", {
364
- [dataAttr]: '',
365
- ref: ref,
366
- style: {
367
- display: 'contents'
368
- },
369
- children: child
370
- });
371
- }
372
- return wrapped;
412
+ return () => {
413
+ var _wrapperRef$current2;
414
+ const el = (_wrapperRef$current2 = wrapperRef.current) === null || _wrapperRef$current2 === void 0 ? void 0 : _wrapperRef$current2.firstElementChild;
415
+ if (el) el.removeAttribute(dataAttr);
416
+ registry.unregisterStep(featureId, index, parentStep ?? null);
417
+ };
418
+ }, [featureId, index, parentStep]); // eslint-disable-line react-hooks/exhaustive-deps
419
+
420
+ return /*#__PURE__*/jsxRuntime.jsx("span", {
421
+ ref: wrapperRef,
422
+ style: {
423
+ display: 'contents'
424
+ },
425
+ children: react.Children.only(children)
426
+ });
373
427
  }
374
428
 
375
429
  // ═══════════════════════════════════════════════════════════════════════════
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createContext, useContext, useRef, useCallback, useEffect, Children, cloneElement, useState } from 'react';
1
+ import { createContext, useContext, useRef, useCallback, useEffect, Children, useState } from 'react';
2
2
  import { jsx } from 'react/jsx-runtime';
3
3
 
4
4
  /**
@@ -27,18 +27,41 @@ function useFeatureScope() {
27
27
  /**
28
28
  * FeatureRegistry
29
29
  *
30
- * The live map of everything currently mounted in the React tree.
30
+ * The live map of everything registered in the React tree — both mounted
31
+ * (full entries) and unmounted (ghost entries).
31
32
  *
32
- * Features — registered by EventopTarget
33
- * Flow steps registered by EventopStep, attached to a feature by id
33
+ * ┌─────────────────────────────────────────────────────────────────────┐
34
+ * │ Full entry component is currently mounted │
35
+ * │ { id, name, description, route, selector, ... } │
36
+ * │ │
37
+ * │ Ghost entry — component has unmounted (navigated away) │
38
+ * │ { id, name, description, route, selector: null, _ghost: true } │
39
+ * └─────────────────────────────────────────────────────────────────────┘
34
40
  *
35
- * Both features and steps can live anywhere in the component tree.
36
- * They don't need to be co-located. An EventopStep just needs to know
37
- * which feature id it belongs to.
41
+ * Why ghosts?
42
+ *
43
+ * The AI system prompt is built from the registry snapshot. Without ghosts,
44
+ * features on other pages don't exist in the snapshot when the user is
45
+ * elsewhere — the AI can't pick them, so cross-page tours never start.
46
+ *
47
+ * With ghosts, every feature the app has ever rendered stays in the
48
+ * snapshot permanently. The AI always has the full picture. The tour
49
+ * runner already resolves selectors lazily (after navigation), so a
50
+ * null selector on a ghost is fine — it gets filled in at show-time
51
+ * once the component mounts on the target page.
52
+ *
53
+ * Lifecycle:
54
+ *
55
+ * EventopTarget mounts → registerFeature() → full entry
56
+ * EventopTarget unmounts → unregisterFeature() → ghost entry (metadata kept)
57
+ * EventopTarget remounts → registerFeature() → full entry again (selector restored)
58
+ *
59
+ * Flow steps follow the same pattern — they're pruned on unmount but the
60
+ * parent feature ghost keeps the feature visible to the AI.
38
61
  *
39
62
  * Nested steps:
40
- * Steps can themselves have children steps (sub-steps) by passing
41
- * a parentStep prop to EventopStep. This lets you model flows like:
63
+ * Steps can have children steps (sub-steps) by passing a parentStep prop.
64
+ * This lets you model flows like:
42
65
  *
43
66
  * Feature: "Create styled text"
44
67
  * Step 0: Click Add Text
@@ -47,17 +70,10 @@ function useFeatureScope() {
47
70
  * Step 1.1: Open font picker ← parentStep: 1, index: 1
48
71
  * Step 1.2: Choose a font ← parentStep: 1, index: 2
49
72
  * Step 2: Click Done
50
- *
51
- * In practice most flows are flat (index 0, 1, 2, 3...) but the
52
- * registry supports nested depth for complex interactions.
53
- *
54
- * Route awareness:
55
- * Features can declare the pathname they live on via `route`.
56
- * The snapshot() includes this so the SDK core can navigate
57
- * automatically when a tour step targets a feature on a different page.
58
73
  */
59
74
  function createFeatureRegistry() {
60
75
  // Map<featureId, featureData>
76
+ // Values are either full entries or ghost entries (_ghost: true)
61
77
  const features = new Map();
62
78
  // Map<featureId, Map<stepKey, stepData>>
63
79
  const flowSteps = new Map();
@@ -69,20 +85,37 @@ function createFeatureRegistry() {
69
85
  // ── Feature registration ─────────────────────────────────────────────────
70
86
 
71
87
  function registerFeature(feature) {
88
+ // Restore a ghost or create a fresh full entry.
89
+ // Always overwrite so a remount gets the latest selector.
72
90
  features.set(feature.id, {
73
91
  ...feature,
92
+ _ghost: false,
74
93
  _registeredAt: Date.now()
75
94
  });
76
95
  notify();
77
96
  }
78
97
  function unregisterFeature(id) {
98
+ if (!features.has(id)) return;
99
+
100
+ // Downgrade to ghost — keep all metadata, null out the live selector.
101
+ // Flow steps are pruned separately (their DOM elements are gone too).
102
+ const feature = features.get(id);
79
103
  features.set(id, {
80
- ...existing,
104
+ id: feature.id,
105
+ name: feature.name,
106
+ description: feature.description,
107
+ route: feature.route || null,
108
+ navigate: feature.navigate || null,
109
+ navigateWaitFor: feature.navigateWaitFor || null,
110
+ _registeredAt: feature._registeredAt,
81
111
  selector: null,
82
112
  advanceOn: null,
83
113
  waitFor: null,
84
114
  _ghost: true
85
115
  });
116
+
117
+ // Prune flow steps — their selectors are dead too.
118
+ // They'll re-register when the component remounts on the target page.
86
119
  flowSteps.delete(id);
87
120
  notify();
88
121
  }
@@ -128,41 +161,62 @@ function createFeatureRegistry() {
128
161
  }
129
162
 
130
163
  /**
131
- * Returns the live feature array in the format the SDK core expects.
164
+ * Returns the full feature array both live and ghost entries.
132
165
  *
133
- * `route` is now included in every feature entry so the core can detect
134
- * when navigation is needed before showing a step.
166
+ * Ghost entries have selector: null. The SDK core handles this correctly:
167
+ * - The AI system prompt uses name/description/route (always present)
168
+ * - The tour runner resolves the selector lazily after navigation,
169
+ * at which point the component has remounted and re-registered
170
+ *
171
+ * screen.check() returns false for ghosts so the SDK knows navigation
172
+ * is needed before showing the step.
135
173
  */
136
174
  function snapshot() {
137
175
  return Array.from(features.values()).map(feature => {
138
176
  const flow = buildFlow(feature.id);
177
+ const isGhost = feature._ghost === true;
139
178
  return {
140
179
  id: feature.id,
141
180
  name: feature.name,
142
181
  description: feature.description,
143
- selector: feature.selector,
182
+ selector: feature.selector || null,
144
183
  route: feature.route || null,
145
184
  advanceOn: feature.advanceOn || null,
146
185
  waitFor: feature.waitFor || null,
186
+ _ghost: isGhost,
147
187
  ...(flow ? {
148
188
  flow
149
189
  } : {}),
150
190
  screen: {
151
191
  id: feature.id,
152
- check: () => features.has(feature.id),
192
+ // Ghost entries always fail the check SDK knows to navigate
193
+ check: () => {
194
+ var _features$get;
195
+ return ((_features$get = features.get(feature.id)) === null || _features$get === void 0 ? void 0 : _features$get._ghost) !== true;
196
+ },
153
197
  navigate: feature.navigate || null,
154
198
  waitFor: feature.navigateWaitFor || feature.selector || null
155
199
  }
156
200
  };
157
201
  });
158
202
  }
203
+
204
+ // ── Helpers ───────────────────────────────────────────────────────────────
205
+
206
+ /**
207
+ * Returns true only for fully mounted (non-ghost) features.
208
+ */
209
+ function isRegistered(id) {
210
+ const f = features.get(id);
211
+ return !!f && !f._ghost;
212
+ }
159
213
  return {
160
214
  registerFeature,
161
215
  unregisterFeature,
162
216
  registerStep,
163
217
  unregisterStep,
164
218
  snapshot,
165
- isRegistered: id => features.has(id),
219
+ isRegistered,
166
220
  subscribe: fn => {
167
221
  listeners.add(fn);
168
222
  return () => listeners.delete(fn);
@@ -259,18 +313,28 @@ function EventopTarget({
259
313
  navigate,
260
314
  navigateWaitFor,
261
315
  advanceOn,
262
- waitFor,
263
- ...rest
316
+ waitFor
264
317
  }) {
265
318
  const registry = useRegistry();
266
- const ref = useRef(null);
319
+ const wrapperRef = useRef(null);
267
320
  const dataAttr = `data-evtp-${id}`;
268
321
  const selector = `[${dataAttr}]`;
269
322
  useEffect(() => {
323
+ var _wrapperRef$current;
270
324
  if (!id || !name) {
271
325
  console.warn('[Eventop] <EventopTarget> requires id and name props.');
272
326
  return;
273
327
  }
328
+
329
+ // Set the attribute directly on the first real child DOM element.
330
+ // This bypasses React's prop system entirely — works regardless of whether
331
+ // the wrapped component forwards unknown props.
332
+ const firstChild = (_wrapperRef$current = wrapperRef.current) === null || _wrapperRef$current === void 0 ? void 0 : _wrapperRef$current.firstElementChild;
333
+ if (firstChild) {
334
+ firstChild.setAttribute(dataAttr, '');
335
+ } else {
336
+ console.warn(`[Eventop] <EventopTarget id="${id}"> could not find a child DOM element to attach to. ` + `Make sure the wrapped component renders at least one DOM element.`);
337
+ }
274
338
  registry.registerFeature({
275
339
  id,
276
340
  name,
@@ -285,32 +349,24 @@ function EventopTarget({
285
349
  ...advanceOn
286
350
  } : null
287
351
  });
288
- return () => registry.unregisterFeature(id);
289
- }, [id, name, description, route]);
290
- const child = Children.only(children);
291
- let wrapped;
292
- try {
293
- wrapped = /*#__PURE__*/cloneElement(child, {
294
- [dataAttr]: '',
295
- ref: node => {
296
- ref.current = node;
297
- const originalRef = child.ref;
298
- if (typeof originalRef === 'function') originalRef(node);else if (originalRef && 'current' in originalRef) originalRef.current = node;
299
- }
300
- });
301
- } catch {
302
- wrapped = /*#__PURE__*/jsx("span", {
303
- [dataAttr]: '',
304
- ref: ref,
352
+ return () => {
353
+ var _wrapperRef$current2;
354
+ // Clean up the injected attribute on unmount
355
+ const el = (_wrapperRef$current2 = wrapperRef.current) === null || _wrapperRef$current2 === void 0 ? void 0 : _wrapperRef$current2.firstElementChild;
356
+ if (el) el.removeAttribute(dataAttr);
357
+ registry.unregisterFeature(id);
358
+ };
359
+ }, [id, name, description, route]); // eslint-disable-line react-hooks/exhaustive-deps
360
+
361
+ return /*#__PURE__*/jsx(EventopFeatureScopeContext.Provider, {
362
+ value: id,
363
+ children: /*#__PURE__*/jsx("span", {
364
+ ref: wrapperRef,
305
365
  style: {
306
366
  display: 'contents'
307
367
  },
308
- children: child
309
- });
310
- }
311
- return /*#__PURE__*/jsx(EventopFeatureScopeContext.Provider, {
312
- value: id,
313
- children: wrapped
368
+ children: Children.only(children)
369
+ })
314
370
  });
315
371
  }
316
372
 
@@ -325,7 +381,7 @@ function EventopStep({
325
381
  const registry = useRegistry();
326
382
  const featureScope = useFeatureScope();
327
383
  const featureId = feature || featureScope;
328
- const ref = useRef(null);
384
+ const wrapperRef = useRef(null);
329
385
  if (!featureId) {
330
386
  console.warn('[Eventop] <EventopStep> needs either a feature prop or an <EventopTarget> ancestor.');
331
387
  }
@@ -335,7 +391,14 @@ function EventopStep({
335
391
  const dataAttr = `data-evtp-step-${featureId}-${parentStep != null ? `${parentStep}-` : ''}${index}`;
336
392
  const selector = `[${dataAttr}]`;
337
393
  useEffect(() => {
394
+ var _wrapperRef$current;
338
395
  if (!featureId || index == null) return;
396
+
397
+ // Inject attribute directly onto the first real child DOM element
398
+ const firstChild = (_wrapperRef$current = wrapperRef.current) === null || _wrapperRef$current === void 0 ? void 0 : _wrapperRef$current.firstElementChild;
399
+ if (firstChild) {
400
+ firstChild.setAttribute(dataAttr, '');
401
+ }
339
402
  registry.registerStep(featureId, index, parentStep ?? null, {
340
403
  selector,
341
404
  waitFor: waitFor || null,
@@ -344,30 +407,21 @@ function EventopStep({
344
407
  ...advanceOn
345
408
  } : null
346
409
  });
347
- return () => registry.unregisterStep(featureId, index, parentStep ?? null);
348
- }, [featureId, index, parentStep]);
349
- const child = Children.only(children);
350
- let wrapped;
351
- try {
352
- wrapped = /*#__PURE__*/cloneElement(child, {
353
- [dataAttr]: '',
354
- ref: node => {
355
- ref.current = node;
356
- const originalRef = child.ref;
357
- if (typeof originalRef === 'function') originalRef(node);else if (originalRef && 'current' in originalRef) originalRef.current = node;
358
- }
359
- });
360
- } catch {
361
- wrapped = /*#__PURE__*/jsx("span", {
362
- [dataAttr]: '',
363
- ref: ref,
364
- style: {
365
- display: 'contents'
366
- },
367
- children: child
368
- });
369
- }
370
- return wrapped;
410
+ return () => {
411
+ var _wrapperRef$current2;
412
+ const el = (_wrapperRef$current2 = wrapperRef.current) === null || _wrapperRef$current2 === void 0 ? void 0 : _wrapperRef$current2.firstElementChild;
413
+ if (el) el.removeAttribute(dataAttr);
414
+ registry.unregisterStep(featureId, index, parentStep ?? null);
415
+ };
416
+ }, [featureId, index, parentStep]); // eslint-disable-line react-hooks/exhaustive-deps
417
+
418
+ return /*#__PURE__*/jsx("span", {
419
+ ref: wrapperRef,
420
+ style: {
421
+ display: 'contents'
422
+ },
423
+ children: Children.only(children)
424
+ });
371
425
  }
372
426
 
373
427
  // ═══════════════════════════════════════════════════════════════════════════
@@ -29,18 +29,41 @@ function useFeatureScope() {
29
29
  /**
30
30
  * FeatureRegistry
31
31
  *
32
- * The live map of everything currently mounted in the React tree.
32
+ * The live map of everything registered in the React tree — both mounted
33
+ * (full entries) and unmounted (ghost entries).
33
34
  *
34
- * Features — registered by EventopTarget
35
- * Flow steps registered by EventopStep, attached to a feature by id
35
+ * ┌─────────────────────────────────────────────────────────────────────┐
36
+ * │ Full entry component is currently mounted │
37
+ * │ { id, name, description, route, selector, ... } │
38
+ * │ │
39
+ * │ Ghost entry — component has unmounted (navigated away) │
40
+ * │ { id, name, description, route, selector: null, _ghost: true } │
41
+ * └─────────────────────────────────────────────────────────────────────┘
36
42
  *
37
- * Both features and steps can live anywhere in the component tree.
38
- * They don't need to be co-located. An EventopStep just needs to know
39
- * which feature id it belongs to.
43
+ * Why ghosts?
44
+ *
45
+ * The AI system prompt is built from the registry snapshot. Without ghosts,
46
+ * features on other pages don't exist in the snapshot when the user is
47
+ * elsewhere — the AI can't pick them, so cross-page tours never start.
48
+ *
49
+ * With ghosts, every feature the app has ever rendered stays in the
50
+ * snapshot permanently. The AI always has the full picture. The tour
51
+ * runner already resolves selectors lazily (after navigation), so a
52
+ * null selector on a ghost is fine — it gets filled in at show-time
53
+ * once the component mounts on the target page.
54
+ *
55
+ * Lifecycle:
56
+ *
57
+ * EventopTarget mounts → registerFeature() → full entry
58
+ * EventopTarget unmounts → unregisterFeature() → ghost entry (metadata kept)
59
+ * EventopTarget remounts → registerFeature() → full entry again (selector restored)
60
+ *
61
+ * Flow steps follow the same pattern — they're pruned on unmount but the
62
+ * parent feature ghost keeps the feature visible to the AI.
40
63
  *
41
64
  * Nested steps:
42
- * Steps can themselves have children steps (sub-steps) by passing
43
- * a parentStep prop to EventopStep. This lets you model flows like:
65
+ * Steps can have children steps (sub-steps) by passing a parentStep prop.
66
+ * This lets you model flows like:
44
67
  *
45
68
  * Feature: "Create styled text"
46
69
  * Step 0: Click Add Text
@@ -49,17 +72,10 @@ function useFeatureScope() {
49
72
  * Step 1.1: Open font picker ← parentStep: 1, index: 1
50
73
  * Step 1.2: Choose a font ← parentStep: 1, index: 2
51
74
  * Step 2: Click Done
52
- *
53
- * In practice most flows are flat (index 0, 1, 2, 3...) but the
54
- * registry supports nested depth for complex interactions.
55
- *
56
- * Route awareness:
57
- * Features can declare the pathname they live on via `route`.
58
- * The snapshot() includes this so the SDK core can navigate
59
- * automatically when a tour step targets a feature on a different page.
60
75
  */
61
76
  function createFeatureRegistry() {
62
77
  // Map<featureId, featureData>
78
+ // Values are either full entries or ghost entries (_ghost: true)
63
79
  const features = new Map();
64
80
  // Map<featureId, Map<stepKey, stepData>>
65
81
  const flowSteps = new Map();
@@ -71,20 +87,37 @@ function createFeatureRegistry() {
71
87
  // ── Feature registration ─────────────────────────────────────────────────
72
88
 
73
89
  function registerFeature(feature) {
90
+ // Restore a ghost or create a fresh full entry.
91
+ // Always overwrite so a remount gets the latest selector.
74
92
  features.set(feature.id, {
75
93
  ...feature,
94
+ _ghost: false,
76
95
  _registeredAt: Date.now()
77
96
  });
78
97
  notify();
79
98
  }
80
99
  function unregisterFeature(id) {
100
+ if (!features.has(id)) return;
101
+
102
+ // Downgrade to ghost — keep all metadata, null out the live selector.
103
+ // Flow steps are pruned separately (their DOM elements are gone too).
104
+ const feature = features.get(id);
81
105
  features.set(id, {
82
- ...existing,
106
+ id: feature.id,
107
+ name: feature.name,
108
+ description: feature.description,
109
+ route: feature.route || null,
110
+ navigate: feature.navigate || null,
111
+ navigateWaitFor: feature.navigateWaitFor || null,
112
+ _registeredAt: feature._registeredAt,
83
113
  selector: null,
84
114
  advanceOn: null,
85
115
  waitFor: null,
86
116
  _ghost: true
87
117
  });
118
+
119
+ // Prune flow steps — their selectors are dead too.
120
+ // They'll re-register when the component remounts on the target page.
88
121
  flowSteps.delete(id);
89
122
  notify();
90
123
  }
@@ -130,41 +163,62 @@ function createFeatureRegistry() {
130
163
  }
131
164
 
132
165
  /**
133
- * Returns the live feature array in the format the SDK core expects.
166
+ * Returns the full feature array both live and ghost entries.
134
167
  *
135
- * `route` is now included in every feature entry so the core can detect
136
- * when navigation is needed before showing a step.
168
+ * Ghost entries have selector: null. The SDK core handles this correctly:
169
+ * - The AI system prompt uses name/description/route (always present)
170
+ * - The tour runner resolves the selector lazily after navigation,
171
+ * at which point the component has remounted and re-registered
172
+ *
173
+ * screen.check() returns false for ghosts so the SDK knows navigation
174
+ * is needed before showing the step.
137
175
  */
138
176
  function snapshot() {
139
177
  return Array.from(features.values()).map(feature => {
140
178
  const flow = buildFlow(feature.id);
179
+ const isGhost = feature._ghost === true;
141
180
  return {
142
181
  id: feature.id,
143
182
  name: feature.name,
144
183
  description: feature.description,
145
- selector: feature.selector,
184
+ selector: feature.selector || null,
146
185
  route: feature.route || null,
147
186
  advanceOn: feature.advanceOn || null,
148
187
  waitFor: feature.waitFor || null,
188
+ _ghost: isGhost,
149
189
  ...(flow ? {
150
190
  flow
151
191
  } : {}),
152
192
  screen: {
153
193
  id: feature.id,
154
- check: () => features.has(feature.id),
194
+ // Ghost entries always fail the check SDK knows to navigate
195
+ check: () => {
196
+ var _features$get;
197
+ return ((_features$get = features.get(feature.id)) === null || _features$get === void 0 ? void 0 : _features$get._ghost) !== true;
198
+ },
155
199
  navigate: feature.navigate || null,
156
200
  waitFor: feature.navigateWaitFor || feature.selector || null
157
201
  }
158
202
  };
159
203
  });
160
204
  }
205
+
206
+ // ── Helpers ───────────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Returns true only for fully mounted (non-ghost) features.
210
+ */
211
+ function isRegistered(id) {
212
+ const f = features.get(id);
213
+ return !!f && !f._ghost;
214
+ }
161
215
  return {
162
216
  registerFeature,
163
217
  unregisterFeature,
164
218
  registerStep,
165
219
  unregisterStep,
166
220
  snapshot,
167
- isRegistered: id => features.has(id),
221
+ isRegistered,
168
222
  subscribe: fn => {
169
223
  listeners.add(fn);
170
224
  return () => listeners.delete(fn);
@@ -261,18 +315,28 @@ function EventopTarget({
261
315
  navigate,
262
316
  navigateWaitFor,
263
317
  advanceOn,
264
- waitFor,
265
- ...rest
318
+ waitFor
266
319
  }) {
267
320
  const registry = useRegistry();
268
- const ref = react.useRef(null);
321
+ const wrapperRef = react.useRef(null);
269
322
  const dataAttr = `data-evtp-${id}`;
270
323
  const selector = `[${dataAttr}]`;
271
324
  react.useEffect(() => {
325
+ var _wrapperRef$current;
272
326
  if (!id || !name) {
273
327
  console.warn('[Eventop] <EventopTarget> requires id and name props.');
274
328
  return;
275
329
  }
330
+
331
+ // Set the attribute directly on the first real child DOM element.
332
+ // This bypasses React's prop system entirely — works regardless of whether
333
+ // the wrapped component forwards unknown props.
334
+ const firstChild = (_wrapperRef$current = wrapperRef.current) === null || _wrapperRef$current === void 0 ? void 0 : _wrapperRef$current.firstElementChild;
335
+ if (firstChild) {
336
+ firstChild.setAttribute(dataAttr, '');
337
+ } else {
338
+ console.warn(`[Eventop] <EventopTarget id="${id}"> could not find a child DOM element to attach to. ` + `Make sure the wrapped component renders at least one DOM element.`);
339
+ }
276
340
  registry.registerFeature({
277
341
  id,
278
342
  name,
@@ -287,32 +351,24 @@ function EventopTarget({
287
351
  ...advanceOn
288
352
  } : null
289
353
  });
290
- return () => registry.unregisterFeature(id);
291
- }, [id, name, description, route]);
292
- const child = react.Children.only(children);
293
- let wrapped;
294
- try {
295
- wrapped = /*#__PURE__*/react.cloneElement(child, {
296
- [dataAttr]: '',
297
- ref: node => {
298
- ref.current = node;
299
- const originalRef = child.ref;
300
- if (typeof originalRef === 'function') originalRef(node);else if (originalRef && 'current' in originalRef) originalRef.current = node;
301
- }
302
- });
303
- } catch {
304
- wrapped = /*#__PURE__*/jsxRuntime.jsx("span", {
305
- [dataAttr]: '',
306
- ref: ref,
354
+ return () => {
355
+ var _wrapperRef$current2;
356
+ // Clean up the injected attribute on unmount
357
+ const el = (_wrapperRef$current2 = wrapperRef.current) === null || _wrapperRef$current2 === void 0 ? void 0 : _wrapperRef$current2.firstElementChild;
358
+ if (el) el.removeAttribute(dataAttr);
359
+ registry.unregisterFeature(id);
360
+ };
361
+ }, [id, name, description, route]); // eslint-disable-line react-hooks/exhaustive-deps
362
+
363
+ return /*#__PURE__*/jsxRuntime.jsx(EventopFeatureScopeContext.Provider, {
364
+ value: id,
365
+ children: /*#__PURE__*/jsxRuntime.jsx("span", {
366
+ ref: wrapperRef,
307
367
  style: {
308
368
  display: 'contents'
309
369
  },
310
- children: child
311
- });
312
- }
313
- return /*#__PURE__*/jsxRuntime.jsx(EventopFeatureScopeContext.Provider, {
314
- value: id,
315
- children: wrapped
370
+ children: react.Children.only(children)
371
+ })
316
372
  });
317
373
  }
318
374
 
@@ -327,7 +383,7 @@ function EventopStep({
327
383
  const registry = useRegistry();
328
384
  const featureScope = useFeatureScope();
329
385
  const featureId = feature || featureScope;
330
- const ref = react.useRef(null);
386
+ const wrapperRef = react.useRef(null);
331
387
  if (!featureId) {
332
388
  console.warn('[Eventop] <EventopStep> needs either a feature prop or an <EventopTarget> ancestor.');
333
389
  }
@@ -337,7 +393,14 @@ function EventopStep({
337
393
  const dataAttr = `data-evtp-step-${featureId}-${parentStep != null ? `${parentStep}-` : ''}${index}`;
338
394
  const selector = `[${dataAttr}]`;
339
395
  react.useEffect(() => {
396
+ var _wrapperRef$current;
340
397
  if (!featureId || index == null) return;
398
+
399
+ // Inject attribute directly onto the first real child DOM element
400
+ const firstChild = (_wrapperRef$current = wrapperRef.current) === null || _wrapperRef$current === void 0 ? void 0 : _wrapperRef$current.firstElementChild;
401
+ if (firstChild) {
402
+ firstChild.setAttribute(dataAttr, '');
403
+ }
341
404
  registry.registerStep(featureId, index, parentStep ?? null, {
342
405
  selector,
343
406
  waitFor: waitFor || null,
@@ -346,30 +409,21 @@ function EventopStep({
346
409
  ...advanceOn
347
410
  } : null
348
411
  });
349
- return () => registry.unregisterStep(featureId, index, parentStep ?? null);
350
- }, [featureId, index, parentStep]);
351
- const child = react.Children.only(children);
352
- let wrapped;
353
- try {
354
- wrapped = /*#__PURE__*/react.cloneElement(child, {
355
- [dataAttr]: '',
356
- ref: node => {
357
- ref.current = node;
358
- const originalRef = child.ref;
359
- if (typeof originalRef === 'function') originalRef(node);else if (originalRef && 'current' in originalRef) originalRef.current = node;
360
- }
361
- });
362
- } catch {
363
- wrapped = /*#__PURE__*/jsxRuntime.jsx("span", {
364
- [dataAttr]: '',
365
- ref: ref,
366
- style: {
367
- display: 'contents'
368
- },
369
- children: child
370
- });
371
- }
372
- return wrapped;
412
+ return () => {
413
+ var _wrapperRef$current2;
414
+ const el = (_wrapperRef$current2 = wrapperRef.current) === null || _wrapperRef$current2 === void 0 ? void 0 : _wrapperRef$current2.firstElementChild;
415
+ if (el) el.removeAttribute(dataAttr);
416
+ registry.unregisterStep(featureId, index, parentStep ?? null);
417
+ };
418
+ }, [featureId, index, parentStep]); // eslint-disable-line react-hooks/exhaustive-deps
419
+
420
+ return /*#__PURE__*/jsxRuntime.jsx("span", {
421
+ ref: wrapperRef,
422
+ style: {
423
+ display: 'contents'
424
+ },
425
+ children: react.Children.only(children)
426
+ });
373
427
  }
374
428
 
375
429
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1,4 +1,4 @@
1
- import { createContext, useContext, useRef, useCallback, useEffect, Children, cloneElement, useState } from 'react';
1
+ import { createContext, useContext, useRef, useCallback, useEffect, Children, useState } from 'react';
2
2
  import { jsx } from 'react/jsx-runtime';
3
3
 
4
4
  /**
@@ -27,18 +27,41 @@ function useFeatureScope() {
27
27
  /**
28
28
  * FeatureRegistry
29
29
  *
30
- * The live map of everything currently mounted in the React tree.
30
+ * The live map of everything registered in the React tree — both mounted
31
+ * (full entries) and unmounted (ghost entries).
31
32
  *
32
- * Features — registered by EventopTarget
33
- * Flow steps registered by EventopStep, attached to a feature by id
33
+ * ┌─────────────────────────────────────────────────────────────────────┐
34
+ * │ Full entry component is currently mounted │
35
+ * │ { id, name, description, route, selector, ... } │
36
+ * │ │
37
+ * │ Ghost entry — component has unmounted (navigated away) │
38
+ * │ { id, name, description, route, selector: null, _ghost: true } │
39
+ * └─────────────────────────────────────────────────────────────────────┘
34
40
  *
35
- * Both features and steps can live anywhere in the component tree.
36
- * They don't need to be co-located. An EventopStep just needs to know
37
- * which feature id it belongs to.
41
+ * Why ghosts?
42
+ *
43
+ * The AI system prompt is built from the registry snapshot. Without ghosts,
44
+ * features on other pages don't exist in the snapshot when the user is
45
+ * elsewhere — the AI can't pick them, so cross-page tours never start.
46
+ *
47
+ * With ghosts, every feature the app has ever rendered stays in the
48
+ * snapshot permanently. The AI always has the full picture. The tour
49
+ * runner already resolves selectors lazily (after navigation), so a
50
+ * null selector on a ghost is fine — it gets filled in at show-time
51
+ * once the component mounts on the target page.
52
+ *
53
+ * Lifecycle:
54
+ *
55
+ * EventopTarget mounts → registerFeature() → full entry
56
+ * EventopTarget unmounts → unregisterFeature() → ghost entry (metadata kept)
57
+ * EventopTarget remounts → registerFeature() → full entry again (selector restored)
58
+ *
59
+ * Flow steps follow the same pattern — they're pruned on unmount but the
60
+ * parent feature ghost keeps the feature visible to the AI.
38
61
  *
39
62
  * Nested steps:
40
- * Steps can themselves have children steps (sub-steps) by passing
41
- * a parentStep prop to EventopStep. This lets you model flows like:
63
+ * Steps can have children steps (sub-steps) by passing a parentStep prop.
64
+ * This lets you model flows like:
42
65
  *
43
66
  * Feature: "Create styled text"
44
67
  * Step 0: Click Add Text
@@ -47,17 +70,10 @@ function useFeatureScope() {
47
70
  * Step 1.1: Open font picker ← parentStep: 1, index: 1
48
71
  * Step 1.2: Choose a font ← parentStep: 1, index: 2
49
72
  * Step 2: Click Done
50
- *
51
- * In practice most flows are flat (index 0, 1, 2, 3...) but the
52
- * registry supports nested depth for complex interactions.
53
- *
54
- * Route awareness:
55
- * Features can declare the pathname they live on via `route`.
56
- * The snapshot() includes this so the SDK core can navigate
57
- * automatically when a tour step targets a feature on a different page.
58
73
  */
59
74
  function createFeatureRegistry() {
60
75
  // Map<featureId, featureData>
76
+ // Values are either full entries or ghost entries (_ghost: true)
61
77
  const features = new Map();
62
78
  // Map<featureId, Map<stepKey, stepData>>
63
79
  const flowSteps = new Map();
@@ -69,20 +85,37 @@ function createFeatureRegistry() {
69
85
  // ── Feature registration ─────────────────────────────────────────────────
70
86
 
71
87
  function registerFeature(feature) {
88
+ // Restore a ghost or create a fresh full entry.
89
+ // Always overwrite so a remount gets the latest selector.
72
90
  features.set(feature.id, {
73
91
  ...feature,
92
+ _ghost: false,
74
93
  _registeredAt: Date.now()
75
94
  });
76
95
  notify();
77
96
  }
78
97
  function unregisterFeature(id) {
98
+ if (!features.has(id)) return;
99
+
100
+ // Downgrade to ghost — keep all metadata, null out the live selector.
101
+ // Flow steps are pruned separately (their DOM elements are gone too).
102
+ const feature = features.get(id);
79
103
  features.set(id, {
80
- ...existing,
104
+ id: feature.id,
105
+ name: feature.name,
106
+ description: feature.description,
107
+ route: feature.route || null,
108
+ navigate: feature.navigate || null,
109
+ navigateWaitFor: feature.navigateWaitFor || null,
110
+ _registeredAt: feature._registeredAt,
81
111
  selector: null,
82
112
  advanceOn: null,
83
113
  waitFor: null,
84
114
  _ghost: true
85
115
  });
116
+
117
+ // Prune flow steps — their selectors are dead too.
118
+ // They'll re-register when the component remounts on the target page.
86
119
  flowSteps.delete(id);
87
120
  notify();
88
121
  }
@@ -128,41 +161,62 @@ function createFeatureRegistry() {
128
161
  }
129
162
 
130
163
  /**
131
- * Returns the live feature array in the format the SDK core expects.
164
+ * Returns the full feature array both live and ghost entries.
132
165
  *
133
- * `route` is now included in every feature entry so the core can detect
134
- * when navigation is needed before showing a step.
166
+ * Ghost entries have selector: null. The SDK core handles this correctly:
167
+ * - The AI system prompt uses name/description/route (always present)
168
+ * - The tour runner resolves the selector lazily after navigation,
169
+ * at which point the component has remounted and re-registered
170
+ *
171
+ * screen.check() returns false for ghosts so the SDK knows navigation
172
+ * is needed before showing the step.
135
173
  */
136
174
  function snapshot() {
137
175
  return Array.from(features.values()).map(feature => {
138
176
  const flow = buildFlow(feature.id);
177
+ const isGhost = feature._ghost === true;
139
178
  return {
140
179
  id: feature.id,
141
180
  name: feature.name,
142
181
  description: feature.description,
143
- selector: feature.selector,
182
+ selector: feature.selector || null,
144
183
  route: feature.route || null,
145
184
  advanceOn: feature.advanceOn || null,
146
185
  waitFor: feature.waitFor || null,
186
+ _ghost: isGhost,
147
187
  ...(flow ? {
148
188
  flow
149
189
  } : {}),
150
190
  screen: {
151
191
  id: feature.id,
152
- check: () => features.has(feature.id),
192
+ // Ghost entries always fail the check SDK knows to navigate
193
+ check: () => {
194
+ var _features$get;
195
+ return ((_features$get = features.get(feature.id)) === null || _features$get === void 0 ? void 0 : _features$get._ghost) !== true;
196
+ },
153
197
  navigate: feature.navigate || null,
154
198
  waitFor: feature.navigateWaitFor || feature.selector || null
155
199
  }
156
200
  };
157
201
  });
158
202
  }
203
+
204
+ // ── Helpers ───────────────────────────────────────────────────────────────
205
+
206
+ /**
207
+ * Returns true only for fully mounted (non-ghost) features.
208
+ */
209
+ function isRegistered(id) {
210
+ const f = features.get(id);
211
+ return !!f && !f._ghost;
212
+ }
159
213
  return {
160
214
  registerFeature,
161
215
  unregisterFeature,
162
216
  registerStep,
163
217
  unregisterStep,
164
218
  snapshot,
165
- isRegistered: id => features.has(id),
219
+ isRegistered,
166
220
  subscribe: fn => {
167
221
  listeners.add(fn);
168
222
  return () => listeners.delete(fn);
@@ -259,18 +313,28 @@ function EventopTarget({
259
313
  navigate,
260
314
  navigateWaitFor,
261
315
  advanceOn,
262
- waitFor,
263
- ...rest
316
+ waitFor
264
317
  }) {
265
318
  const registry = useRegistry();
266
- const ref = useRef(null);
319
+ const wrapperRef = useRef(null);
267
320
  const dataAttr = `data-evtp-${id}`;
268
321
  const selector = `[${dataAttr}]`;
269
322
  useEffect(() => {
323
+ var _wrapperRef$current;
270
324
  if (!id || !name) {
271
325
  console.warn('[Eventop] <EventopTarget> requires id and name props.');
272
326
  return;
273
327
  }
328
+
329
+ // Set the attribute directly on the first real child DOM element.
330
+ // This bypasses React's prop system entirely — works regardless of whether
331
+ // the wrapped component forwards unknown props.
332
+ const firstChild = (_wrapperRef$current = wrapperRef.current) === null || _wrapperRef$current === void 0 ? void 0 : _wrapperRef$current.firstElementChild;
333
+ if (firstChild) {
334
+ firstChild.setAttribute(dataAttr, '');
335
+ } else {
336
+ console.warn(`[Eventop] <EventopTarget id="${id}"> could not find a child DOM element to attach to. ` + `Make sure the wrapped component renders at least one DOM element.`);
337
+ }
274
338
  registry.registerFeature({
275
339
  id,
276
340
  name,
@@ -285,32 +349,24 @@ function EventopTarget({
285
349
  ...advanceOn
286
350
  } : null
287
351
  });
288
- return () => registry.unregisterFeature(id);
289
- }, [id, name, description, route]);
290
- const child = Children.only(children);
291
- let wrapped;
292
- try {
293
- wrapped = /*#__PURE__*/cloneElement(child, {
294
- [dataAttr]: '',
295
- ref: node => {
296
- ref.current = node;
297
- const originalRef = child.ref;
298
- if (typeof originalRef === 'function') originalRef(node);else if (originalRef && 'current' in originalRef) originalRef.current = node;
299
- }
300
- });
301
- } catch {
302
- wrapped = /*#__PURE__*/jsx("span", {
303
- [dataAttr]: '',
304
- ref: ref,
352
+ return () => {
353
+ var _wrapperRef$current2;
354
+ // Clean up the injected attribute on unmount
355
+ const el = (_wrapperRef$current2 = wrapperRef.current) === null || _wrapperRef$current2 === void 0 ? void 0 : _wrapperRef$current2.firstElementChild;
356
+ if (el) el.removeAttribute(dataAttr);
357
+ registry.unregisterFeature(id);
358
+ };
359
+ }, [id, name, description, route]); // eslint-disable-line react-hooks/exhaustive-deps
360
+
361
+ return /*#__PURE__*/jsx(EventopFeatureScopeContext.Provider, {
362
+ value: id,
363
+ children: /*#__PURE__*/jsx("span", {
364
+ ref: wrapperRef,
305
365
  style: {
306
366
  display: 'contents'
307
367
  },
308
- children: child
309
- });
310
- }
311
- return /*#__PURE__*/jsx(EventopFeatureScopeContext.Provider, {
312
- value: id,
313
- children: wrapped
368
+ children: Children.only(children)
369
+ })
314
370
  });
315
371
  }
316
372
 
@@ -325,7 +381,7 @@ function EventopStep({
325
381
  const registry = useRegistry();
326
382
  const featureScope = useFeatureScope();
327
383
  const featureId = feature || featureScope;
328
- const ref = useRef(null);
384
+ const wrapperRef = useRef(null);
329
385
  if (!featureId) {
330
386
  console.warn('[Eventop] <EventopStep> needs either a feature prop or an <EventopTarget> ancestor.');
331
387
  }
@@ -335,7 +391,14 @@ function EventopStep({
335
391
  const dataAttr = `data-evtp-step-${featureId}-${parentStep != null ? `${parentStep}-` : ''}${index}`;
336
392
  const selector = `[${dataAttr}]`;
337
393
  useEffect(() => {
394
+ var _wrapperRef$current;
338
395
  if (!featureId || index == null) return;
396
+
397
+ // Inject attribute directly onto the first real child DOM element
398
+ const firstChild = (_wrapperRef$current = wrapperRef.current) === null || _wrapperRef$current === void 0 ? void 0 : _wrapperRef$current.firstElementChild;
399
+ if (firstChild) {
400
+ firstChild.setAttribute(dataAttr, '');
401
+ }
339
402
  registry.registerStep(featureId, index, parentStep ?? null, {
340
403
  selector,
341
404
  waitFor: waitFor || null,
@@ -344,30 +407,21 @@ function EventopStep({
344
407
  ...advanceOn
345
408
  } : null
346
409
  });
347
- return () => registry.unregisterStep(featureId, index, parentStep ?? null);
348
- }, [featureId, index, parentStep]);
349
- const child = Children.only(children);
350
- let wrapped;
351
- try {
352
- wrapped = /*#__PURE__*/cloneElement(child, {
353
- [dataAttr]: '',
354
- ref: node => {
355
- ref.current = node;
356
- const originalRef = child.ref;
357
- if (typeof originalRef === 'function') originalRef(node);else if (originalRef && 'current' in originalRef) originalRef.current = node;
358
- }
359
- });
360
- } catch {
361
- wrapped = /*#__PURE__*/jsx("span", {
362
- [dataAttr]: '',
363
- ref: ref,
364
- style: {
365
- display: 'contents'
366
- },
367
- children: child
368
- });
369
- }
370
- return wrapped;
410
+ return () => {
411
+ var _wrapperRef$current2;
412
+ const el = (_wrapperRef$current2 = wrapperRef.current) === null || _wrapperRef$current2 === void 0 ? void 0 : _wrapperRef$current2.firstElementChild;
413
+ if (el) el.removeAttribute(dataAttr);
414
+ registry.unregisterStep(featureId, index, parentStep ?? null);
415
+ };
416
+ }, [featureId, index, parentStep]); // eslint-disable-line react-hooks/exhaustive-deps
417
+
418
+ return /*#__PURE__*/jsx("span", {
419
+ ref: wrapperRef,
420
+ style: {
421
+ display: 'contents'
422
+ },
423
+ children: Children.only(children)
424
+ });
371
425
  }
372
426
 
373
427
  // ═══════════════════════════════════════════════════════════════════════════
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventop/sdk",
3
- "version": "1.2.11",
3
+ "version": "1.2.13",
4
4
  "description": "AI-powered guided tours for any web app. Drop-in, themeable, provider-agnostic.",
5
5
  "keywords": [
6
6
  "onboarding",