@eventop/sdk 1.2.10 → 1.2.12

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/README.md CHANGED
@@ -304,6 +304,33 @@ that lives on a different page.
304
304
  Features that share the page where tours are typically started don't need `route`.
305
305
  Only add it to features on other pages.
306
306
 
307
+ ### How the registry stays aware of every page
308
+
309
+ When you navigate away from a page, its `EventopTarget` components unmount. Rather
310
+ than removing them from the registry entirely, the SDK downgrades them to **ghost
311
+ entries** — the metadata (`id`, `name`, `description`, `route`) is kept, only the
312
+ live DOM selector is nulled out.
313
+
314
+ ```
315
+ EventopTarget mounts → full entry { id, name, description, route, selector }
316
+ EventopTarget unmounts → ghost entry { id, name, description, route, selector: null }
317
+ EventopTarget remounts → full entry (selector restored)
318
+ ```
319
+
320
+ This means the AI system prompt always contains every feature the app has ever
321
+ rendered, regardless of which page you're currently on. The AI can plan cross-page
322
+ tours from any starting point without you doing anything extra.
323
+
324
+ The selector is resolved lazily — after navigation completes and the target page's
325
+ components remount, the ghost upgrades back to a full entry and the tour picks up
326
+ the correct selector just before showing the step.
327
+
328
+ > **Note on `id` stability** — ghost entries accumulate for the lifetime of the
329
+ > session, so `id` should identify a **UI capability**, not a data record.
330
+ > Wrapping dynamic list items with unique ids (e.g. `` `project-${p.id}` ``)
331
+ > will create an unbounded number of ghosts. Use a single `EventopTarget` for
332
+ > the repeating pattern instead.
333
+
307
334
  ---
308
335
 
309
336
  ## Multi-step flows
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,14 +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) {
81
- features.delete(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);
105
+ features.set(id, {
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,
113
+ selector: null,
114
+ advanceOn: null,
115
+ waitFor: null,
116
+ _ghost: true
117
+ });
118
+
119
+ // Prune flow steps — their selectors are dead too.
120
+ // They'll re-register when the component remounts on the target page.
82
121
  flowSteps.delete(id);
83
122
  notify();
84
123
  }
@@ -124,41 +163,62 @@ function createFeatureRegistry() {
124
163
  }
125
164
 
126
165
  /**
127
- * Returns the live feature array in the format the SDK core expects.
166
+ * Returns the full feature array both live and ghost entries.
128
167
  *
129
- * `route` is now included in every feature entry so the core can detect
130
- * 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.
131
175
  */
132
176
  function snapshot() {
133
177
  return Array.from(features.values()).map(feature => {
134
178
  const flow = buildFlow(feature.id);
179
+ const isGhost = feature._ghost === true;
135
180
  return {
136
181
  id: feature.id,
137
182
  name: feature.name,
138
183
  description: feature.description,
139
- selector: feature.selector,
184
+ selector: feature.selector || null,
140
185
  route: feature.route || null,
141
186
  advanceOn: feature.advanceOn || null,
142
187
  waitFor: feature.waitFor || null,
188
+ _ghost: isGhost,
143
189
  ...(flow ? {
144
190
  flow
145
191
  } : {}),
146
192
  screen: {
147
193
  id: feature.id,
148
- 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
+ },
149
199
  navigate: feature.navigate || null,
150
200
  waitFor: feature.navigateWaitFor || feature.selector || null
151
201
  }
152
202
  };
153
203
  });
154
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
+ }
155
215
  return {
156
216
  registerFeature,
157
217
  unregisterFeature,
158
218
  registerStep,
159
219
  unregisterStep,
160
220
  snapshot,
161
- isRegistered: id => features.has(id),
221
+ isRegistered,
162
222
  subscribe: fn => {
163
223
  listeners.add(fn);
164
224
  return () => listeners.delete(fn);
package/dist/index.js CHANGED
@@ -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,14 +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) {
79
- features.delete(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);
103
+ features.set(id, {
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,
111
+ selector: null,
112
+ advanceOn: null,
113
+ waitFor: null,
114
+ _ghost: true
115
+ });
116
+
117
+ // Prune flow steps — their selectors are dead too.
118
+ // They'll re-register when the component remounts on the target page.
80
119
  flowSteps.delete(id);
81
120
  notify();
82
121
  }
@@ -122,41 +161,62 @@ function createFeatureRegistry() {
122
161
  }
123
162
 
124
163
  /**
125
- * Returns the live feature array in the format the SDK core expects.
164
+ * Returns the full feature array both live and ghost entries.
126
165
  *
127
- * `route` is now included in every feature entry so the core can detect
128
- * 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.
129
173
  */
130
174
  function snapshot() {
131
175
  return Array.from(features.values()).map(feature => {
132
176
  const flow = buildFlow(feature.id);
177
+ const isGhost = feature._ghost === true;
133
178
  return {
134
179
  id: feature.id,
135
180
  name: feature.name,
136
181
  description: feature.description,
137
- selector: feature.selector,
182
+ selector: feature.selector || null,
138
183
  route: feature.route || null,
139
184
  advanceOn: feature.advanceOn || null,
140
185
  waitFor: feature.waitFor || null,
186
+ _ghost: isGhost,
141
187
  ...(flow ? {
142
188
  flow
143
189
  } : {}),
144
190
  screen: {
145
191
  id: feature.id,
146
- 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
+ },
147
197
  navigate: feature.navigate || null,
148
198
  waitFor: feature.navigateWaitFor || feature.selector || null
149
199
  }
150
200
  };
151
201
  });
152
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
+ }
153
213
  return {
154
214
  registerFeature,
155
215
  unregisterFeature,
156
216
  registerStep,
157
217
  unregisterStep,
158
218
  snapshot,
159
- isRegistered: id => features.has(id),
219
+ isRegistered,
160
220
  subscribe: fn => {
161
221
  listeners.add(fn);
162
222
  return () => listeners.delete(fn);
@@ -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,14 +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) {
81
- features.delete(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);
105
+ features.set(id, {
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,
113
+ selector: null,
114
+ advanceOn: null,
115
+ waitFor: null,
116
+ _ghost: true
117
+ });
118
+
119
+ // Prune flow steps — their selectors are dead too.
120
+ // They'll re-register when the component remounts on the target page.
82
121
  flowSteps.delete(id);
83
122
  notify();
84
123
  }
@@ -124,41 +163,62 @@ function createFeatureRegistry() {
124
163
  }
125
164
 
126
165
  /**
127
- * Returns the live feature array in the format the SDK core expects.
166
+ * Returns the full feature array both live and ghost entries.
128
167
  *
129
- * `route` is now included in every feature entry so the core can detect
130
- * 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.
131
175
  */
132
176
  function snapshot() {
133
177
  return Array.from(features.values()).map(feature => {
134
178
  const flow = buildFlow(feature.id);
179
+ const isGhost = feature._ghost === true;
135
180
  return {
136
181
  id: feature.id,
137
182
  name: feature.name,
138
183
  description: feature.description,
139
- selector: feature.selector,
184
+ selector: feature.selector || null,
140
185
  route: feature.route || null,
141
186
  advanceOn: feature.advanceOn || null,
142
187
  waitFor: feature.waitFor || null,
188
+ _ghost: isGhost,
143
189
  ...(flow ? {
144
190
  flow
145
191
  } : {}),
146
192
  screen: {
147
193
  id: feature.id,
148
- 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
+ },
149
199
  navigate: feature.navigate || null,
150
200
  waitFor: feature.navigateWaitFor || feature.selector || null
151
201
  }
152
202
  };
153
203
  });
154
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
+ }
155
215
  return {
156
216
  registerFeature,
157
217
  unregisterFeature,
158
218
  registerStep,
159
219
  unregisterStep,
160
220
  snapshot,
161
- isRegistered: id => features.has(id),
221
+ isRegistered,
162
222
  subscribe: fn => {
163
223
  listeners.add(fn);
164
224
  return () => listeners.delete(fn);
@@ -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,14 +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) {
79
- features.delete(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);
103
+ features.set(id, {
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,
111
+ selector: null,
112
+ advanceOn: null,
113
+ waitFor: null,
114
+ _ghost: true
115
+ });
116
+
117
+ // Prune flow steps — their selectors are dead too.
118
+ // They'll re-register when the component remounts on the target page.
80
119
  flowSteps.delete(id);
81
120
  notify();
82
121
  }
@@ -122,41 +161,62 @@ function createFeatureRegistry() {
122
161
  }
123
162
 
124
163
  /**
125
- * Returns the live feature array in the format the SDK core expects.
164
+ * Returns the full feature array both live and ghost entries.
126
165
  *
127
- * `route` is now included in every feature entry so the core can detect
128
- * 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.
129
173
  */
130
174
  function snapshot() {
131
175
  return Array.from(features.values()).map(feature => {
132
176
  const flow = buildFlow(feature.id);
177
+ const isGhost = feature._ghost === true;
133
178
  return {
134
179
  id: feature.id,
135
180
  name: feature.name,
136
181
  description: feature.description,
137
- selector: feature.selector,
182
+ selector: feature.selector || null,
138
183
  route: feature.route || null,
139
184
  advanceOn: feature.advanceOn || null,
140
185
  waitFor: feature.waitFor || null,
186
+ _ghost: isGhost,
141
187
  ...(flow ? {
142
188
  flow
143
189
  } : {}),
144
190
  screen: {
145
191
  id: feature.id,
146
- 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
+ },
147
197
  navigate: feature.navigate || null,
148
198
  waitFor: feature.navigateWaitFor || feature.selector || null
149
199
  }
150
200
  };
151
201
  });
152
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
+ }
153
213
  return {
154
214
  registerFeature,
155
215
  unregisterFeature,
156
216
  registerStep,
157
217
  unregisterStep,
158
218
  snapshot,
159
- isRegistered: id => features.has(id),
219
+ isRegistered,
160
220
  subscribe: fn => {
161
221
  listeners.add(fn);
162
222
  return () => listeners.delete(fn);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventop/sdk",
3
- "version": "1.2.10",
3
+ "version": "1.2.12",
4
4
  "description": "AI-powered guided tours for any web app. Drop-in, themeable, provider-agnostic.",
5
5
  "keywords": [
6
6
  "onboarding",