@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 +128 -74
- package/dist/index.js +129 -75
- package/dist/react/index.cjs +128 -74
- package/dist/react/index.js +129 -75
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -29,18 +29,41 @@ function useFeatureScope() {
|
|
|
29
29
|
/**
|
|
30
30
|
* FeatureRegistry
|
|
31
31
|
*
|
|
32
|
-
* The live map of everything
|
|
32
|
+
* The live map of everything registered in the React tree — both mounted
|
|
33
|
+
* (full entries) and unmounted (ghost entries).
|
|
33
34
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
43
|
-
*
|
|
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
|
-
|
|
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
|
|
166
|
+
* Returns the full feature array — both live and ghost entries.
|
|
134
167
|
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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 () =>
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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:
|
|
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
|
|
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 () =>
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
|
|
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,
|
|
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
|
|
30
|
+
* The live map of everything registered in the React tree — both mounted
|
|
31
|
+
* (full entries) and unmounted (ghost entries).
|
|
31
32
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
41
|
-
*
|
|
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
|
-
|
|
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
|
|
164
|
+
* Returns the full feature array — both live and ghost entries.
|
|
132
165
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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 () =>
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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:
|
|
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
|
|
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 () =>
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
|
|
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/dist/react/index.cjs
CHANGED
|
@@ -29,18 +29,41 @@ function useFeatureScope() {
|
|
|
29
29
|
/**
|
|
30
30
|
* FeatureRegistry
|
|
31
31
|
*
|
|
32
|
-
* The live map of everything
|
|
32
|
+
* The live map of everything registered in the React tree — both mounted
|
|
33
|
+
* (full entries) and unmounted (ghost entries).
|
|
33
34
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
43
|
-
*
|
|
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
|
-
|
|
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
|
|
166
|
+
* Returns the full feature array — both live and ghost entries.
|
|
134
167
|
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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 () =>
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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:
|
|
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
|
|
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 () =>
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
|
|
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/react/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContext, useContext, useRef, useCallback, useEffect, Children,
|
|
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
|
|
30
|
+
* The live map of everything registered in the React tree — both mounted
|
|
31
|
+
* (full entries) and unmounted (ghost entries).
|
|
31
32
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
41
|
-
*
|
|
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
|
-
|
|
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
|
|
164
|
+
* Returns the full feature array — both live and ghost entries.
|
|
132
165
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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 () =>
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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:
|
|
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
|
|
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 () =>
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
|
|
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
|
// ═══════════════════════════════════════════════════════════════════════════
|