@apollovisionlabs/guide-core 0.1.1 → 0.3.0

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
@@ -61,7 +61,7 @@ missing-target policy below from ever firing.
61
61
 
62
62
  | Prop | Type | Default | Description |
63
63
  | --- | --- | --- | --- |
64
- | `labels` | `Partial<{ next, previous, finish, close }>` | `{ next: 'Next', previous: 'Back', finish: 'Finish', close: 'Close' }` | The popover's button labels. Defaults are English; override any subset. See "Translations". |
64
+ | `labels` | `Partial<{ next, previous, finish, close, awaitingAction }>` | `{ next: 'Next', previous: 'Back', finish: 'Finish', close: 'Close', awaitingAction: 'Click the highlighted element to continue.' }` | The popover's button labels. Defaults are English; override any subset. See "Translations". |
65
65
  | `zIndex` | `number` | `theme.zIndex.modal` | Stacking level of the spotlight; the popover sits one above it. |
66
66
  | `padding` | `number` | `8` | Margin, in pixels, between the highlighted element and the edge of the spotlight hole. |
67
67
  | `radius` | `number` | `8` | Corner radius, in pixels, of the spotlight hole. |
@@ -101,43 +101,58 @@ the destination.
101
101
 
102
102
  ## Persistence
103
103
 
104
- `GuideStorage` is the two-method interface the provider reads from and writes to:
104
+ `GuideStorage` is the two-method interface both `GuideProvider` and `ChecklistProvider` read from
105
+ and write to. It is generic over the stored value, so one storage serves tour progress and
106
+ checklist progress under different keys:
105
107
 
106
108
  ```ts
107
109
  interface GuideStorage {
108
- read(tourId: string): Promise<TourProgress | null>
109
- write(tourId: string, progress: TourProgress): Promise<void>
110
+ read<T>(key: string): Promise<T | null>
111
+ write<T>(key: string, value: T): Promise<void>
110
112
  }
111
113
  ```
112
114
 
115
+ `GuideProvider` reads and writes tour progress under `tour:<id>`. `ChecklistProvider` reads and
116
+ writes checklist progress under `checklist:<id>`. `HotspotProvider` reads and writes which
117
+ hotspots have been opened under the single key `hotspots:seen`. See
118
+ [ADR 0016](docs/adr/0016-one-storage-contract-for-tours-and-checklists.md) for why they share one
119
+ interface.
120
+
113
121
  `@apollovisionlabs/guide-core` ships `createMemoryStorage()` for tests and `createBrowserStorage(namespace?)` for
114
122
  `localStorage`. Neither talks to a server. An implementation backed by your own API looks like
115
123
  this:
116
124
 
117
125
  ```ts
118
- import type { GuideStorage, TourProgress } from '@apollovisionlabs/guide-core'
126
+ import type { GuideStorage } from '@apollovisionlabs/guide-core'
119
127
 
120
128
  function createServerStorage(): GuideStorage {
121
129
  return {
122
- async read(tourId) {
123
- const response = await fetch(`/api/tours/${tourId}/progress`)
130
+ async read<T>(key: string) {
131
+ const response = await fetch(`/api/guide/${key}`)
124
132
  if (!response.ok) return null
125
- return (await response.json()) as TourProgress
133
+ return (await response.json()) as T
126
134
  },
127
- async write(tourId, progress) {
128
- await fetch(`/api/tours/${tourId}/progress`, {
135
+ async write<T>(key: string, value: T) {
136
+ await fetch(`/api/guide/${key}`, {
129
137
  method: 'PUT',
130
138
  headers: { 'Content-Type': 'application/json' },
131
- body: JSON.stringify(progress),
139
+ body: JSON.stringify(value),
132
140
  })
133
141
  },
134
142
  }
135
143
  }
136
144
  ```
137
145
 
138
- Pass it as the `storage` prop. The provider reads on `start()` (unless an explicit `from` step
139
- index is passed, or `resume: false` is passed) and writes whenever a running tour advances or
140
- completes.
146
+ Pass it as the `storage` prop on either provider. `GuideProvider` reads on `start()` (unless an
147
+ explicit `from` step index is passed, or `resume: false` is passed) and writes whenever a running
148
+ tour advances or completes. `ChecklistProvider` reads once on mount and writes whenever an item is
149
+ ticked, completed or the checklist is dismissed.
150
+
151
+ A value read back from storage is validated before it is trusted (`isTourProgress`,
152
+ `isChecklistProgress`, `isHotspotsProgress`, all exported from `@apollovisionlabs/guide-core`): a
153
+ value that does not
154
+ match the expected shape, from a hand-edited store or an older version of this library, is treated
155
+ the same as nothing stored, rather than crashing or resuming into a broken state.
141
156
 
142
157
  ## Translations
143
158
 
@@ -158,7 +173,8 @@ and nothing English remains:
158
173
 
159
174
  ## Events
160
175
 
161
- `onEvent` on `GuideProvider` receives every lifecycle event as a discriminated union:
176
+ `onEvent` on `GuideProvider`, `ChecklistProvider` and `HotspotProvider` each receive their own
177
+ lifecycle events, as a discriminated union of `GuideEvent`:
162
178
 
163
179
  | Event | Payload | When |
164
180
  | --- | --- | --- |
@@ -167,6 +183,11 @@ and nothing English remains:
167
183
  | `tour:stop` | `{ tourId, stepIndex }` | The tour is stopped before completion. |
168
184
  | `step:show` | `{ tourId, stepIndex, target }` | A step's target is resolved and the step becomes visible. |
169
185
  | `target:missing` | `{ tourId, stepIndex, target }` | A step's target didn't appear within `targetTimeoutMs`. |
186
+ | `checklist:item-complete` | `{ checklistId, itemId }` | An item is completed, by finishing its linked tour or by a manual tick. Not emitted for an item already complete. |
187
+ | `checklist:complete` | `{ checklistId }` | The last incomplete item in a checklist is completed. Fires on every transition into the complete state, so unticking an item and reticking it emits a second time. Deduplicate downstream if you count completions. |
188
+ | `checklist:dismiss` | `{ checklistId }` | `dismiss()` is called. |
189
+ | `hotspot:show` | `{ hotspotId }` | A hotspot's marker is actually drawn on screen. Emitted once per hotspot per mount. |
190
+ | `hotspot:open` | `{ hotspotId }` | The hotspot's bubble is opened, which also marks it seen. Not emitted again for a bubble that is already open. |
170
191
 
171
192
  ## Accessibility
172
193
 
@@ -190,6 +211,265 @@ happens: `'skip'` moves to the next step, `'error'` stops the tour, and `'wait'`
190
211
  pauses and resumes automatically if the target appears later, for instance after a slow async
191
212
  render.
192
213
 
214
+ ## Advancing on an action
215
+
216
+ A step can declare `advanceOn: 'click'` instead of ending on the popover's button:
217
+
218
+ ```ts
219
+ {
220
+ target: 'project.share',
221
+ title: 'Share it',
222
+ body: 'Click the button yourself, this step is interactive.',
223
+ advanceOn: 'click',
224
+ }
225
+ ```
226
+
227
+ The step advances when the user clicks the target, not the popover. `advanceOn` implies
228
+ `interactive`: a step that waits for a click has to let the click through, so `GuideProvider`
229
+ derives both `interactive` and `awaitsAction` on `ActiveStep` from `advanceOn`, rather than
230
+ requiring both to be set by hand. Read `activeStep.interactive` / `activeStep.awaitsAction`, not
231
+ `step.interactive`, which is left `undefined` on a step that only sets `advanceOn`. See
232
+ [ADR 0017](docs/adr/0017-advancing-on-an-action-implies-an-interactive-step.md).
233
+
234
+ `@apollovisionlabs/guide-mui`'s popover reflects `awaitsAction`: no primary button, and the
235
+ `awaitingAction` label in its place (see the `labels` row above). `ArrowRight` is ignored while a
236
+ step awaits its action, since letting it through would be a way around the very thing the step is
237
+ asking for; `Escape` and `ArrowLeft` still work.
238
+
239
+ The click listener is attached, in the bubble phase, to the element resolved when the step
240
+ opened, without `preventDefault` or `stopPropagation`, so your own click handler on the target
241
+ still runs.
242
+
243
+ If your application replaces that DOM node afterward, for instance by re-rendering a list, the
244
+ listener goes with it and the step stops advancing. Nothing notices: the target was found once,
245
+ so the timeout was already cleared, no `target:missing` is emitted and no `wait`, `skip` or
246
+ `error` policy runs. The tour simply sits on that step. The consequence is specific to
247
+ `advanceOn`, even though the cause is not: an `advanceOn` step offers no primary button and
248
+ ignores `ArrowRight`, so a replaced node leaves the tour with `Escape` as its only exit. If the
249
+ element a step points at can be re-created under it, either give it a stable target that survives
250
+ the re-render or use an ordinary step with a Next button.
251
+
252
+ ## Checklist
253
+
254
+ A checklist is a separate feature from the tour: a fixed list of items, each completed by
255
+ finishing a linked tour or by a manual tick. An item can also carry an `href`, which navigates
256
+ and nothing more. `ChecklistProvider` holds
257
+ its state the way `GuideProvider` holds tour state, and nests inside it so that an item can launch
258
+ a tour:
259
+
260
+ ```tsx
261
+ import { GuideProvider, ChecklistProvider, type Tour, type Checklist } from '@apollovisionlabs/guide-core'
262
+ import { GuideTour } from '@apollovisionlabs/guide-mui'
263
+ import { ChecklistLauncher } from '@apollovisionlabs/guide-mui'
264
+
265
+ const tour: Tour = {
266
+ id: 'welcome',
267
+ steps: [{ target: 'sidebar.projects', title: 'Your projects', body: 'Grouped under a project.' }],
268
+ }
269
+
270
+ const onboarding: Checklist = {
271
+ id: 'onboarding',
272
+ items: [
273
+ { id: 'tour', title: 'Take the tour', body: 'Two minutes.', tourId: 'welcome' },
274
+ { id: 'projects', title: 'Open your projects', body: 'See the list.', href: '/projects' },
275
+ { id: 'profile', title: 'Set your name', body: 'Manual, ticked by hand.' },
276
+ ],
277
+ }
278
+
279
+ function App() {
280
+ return (
281
+ <GuideProvider tours={[tour]} navigate={(path) => router.push(path)}>
282
+ <ChecklistProvider checklists={[onboarding]} navigate={(path) => router.push(path)}>
283
+ <Sidebar />
284
+ <GuideTour />
285
+ <ChecklistLauncher checklistId="onboarding" title="Get started" />
286
+ </ChecklistProvider>
287
+ </GuideProvider>
288
+ )
289
+ }
290
+ ```
291
+
292
+ An item with an `href`, or with neither `tourId` nor `href`, is completed by a manual tick only:
293
+ activating an `href` item navigates and stops there, since arriving on a page is not evidence that
294
+ anyone did anything on it. An item with `tourId`
295
+ is completed automatically when that tour is finished (`next()` called on its last step); it can
296
+ also be ticked by hand before that. Completion is idempotent: ticking an already-complete item, or
297
+ finishing a tour whose item is already ticked, does nothing and emits no event.
298
+
299
+ ### `ChecklistProvider` props
300
+
301
+ | Prop | Type | Default | Description |
302
+ | --- | --- | --- | --- |
303
+ | `checklists` | `Checklist[]` | none | The checklists available to `useChecklist`. |
304
+ | `children` | `ReactNode` | none | Your application. |
305
+ | `storage` | `GuideStorage` | none | Persists checklist progress under `checklist:<id>`. See "Persistence". |
306
+ | `translate` | `(key: string) => string` | none | Resolves `titleKey` / `bodyKey` on items. See "Translations". |
307
+ | `navigate` | `(path: string) => void` | none | Called when an item with `href` is activated. |
308
+ | `onEvent` | `(event: GuideEvent) => void` | none | Called for `checklist:item-complete`, `checklist:complete`, `checklist:dismiss`. See "Events". |
309
+
310
+ ### `useChecklist(checklistId)`
311
+
312
+ Returns `{ items, completedCount, total, isComplete, dismissed, restored, activate, toggle, complete, dismiss, reset }`.
313
+ `items` is `ResolvedChecklistItem[]`: `{ id, title, body, completed, tourId?, href? }`, with
314
+ `title` / `body` already resolved through `translate`. `activate(itemId)` runs an item's default
315
+ action (start its tour, navigate to its `href`, or toggle it if it has neither); `toggle` and
316
+ `complete` change completion directly; `dismiss()` and `reset()` act on the whole checklist.
317
+ `restored` is whether this checklist's own initial read from storage has settled: `true`
318
+ immediately with no `storage` prop (there is nothing to wait for), and `true` once this
319
+ checklist's own read has resolved or rejected. It settles independently per checklist, so a
320
+ `ChecklistProvider` holding several checklists never lets a slow or hung read for one hold
321
+ another one's `restored` false; each checklist's read runs concurrently with the others.
322
+
323
+ ### `Checklist` and `ChecklistLauncher` (`@apollovisionlabs/guide-mui`)
324
+
325
+ `Checklist` renders the list inline: a progress bar, one row per item with a checkbox and a
326
+ dismiss button. `ChecklistLauncher` wraps it behind a floating action button showing
327
+ `completedCount/total`, opened as a popover. The popover stays open while items are ticked one
328
+ after another, and closes when an item hands off to something that needs the screen: launching a
329
+ tour or navigating to an `href`. It does not close on a plain tick.
330
+
331
+ ```tsx
332
+ import { Checklist, ChecklistLauncher } from '@apollovisionlabs/guide-mui'
333
+
334
+ <Checklist checklistId="onboarding" title="Get started" />
335
+ // or, as a floating launcher:
336
+ <ChecklistLauncher checklistId="onboarding" title="Get started" placement="bottom-right" />
337
+ ```
338
+
339
+ With a `storage` prop configured on `ChecklistProvider`, both `Checklist` and `ChecklistLauncher`
340
+ wait for their own checklist's restore to settle (`useChecklist(checklistId).restored`) before
341
+ drawing anything, rather than rendering their empty initial state (nothing completed, not
342
+ dismissed) for one paint. The tradeoff: with a slow storage backend, a checklist now appears later
343
+ than it used to, instead of appearing at once and then jumping. A slow or broken read for one
344
+ checklist never holds a different checklist back; each restores on its own.
345
+
346
+ ## Hotspots
347
+
348
+ A hotspot marks one element outside any tour: a small marker that opens a short explanation, and
349
+ optionally a button that starts a tour. Unlike a tour step, a hotspot has no route and no order;
350
+ it just sits at its target until opened. `HotspotProvider` nests inside `GuideProvider`, the same
351
+ way `ChecklistProvider` does, so a hotspot naming a `tourId` can start it:
352
+
353
+ ```tsx
354
+ import {
355
+ GuideProvider,
356
+ HotspotProvider,
357
+ type Hotspot,
358
+ type Tour,
359
+ } from '@apollovisionlabs/guide-core'
360
+ import { GuideTour, Hotspots } from '@apollovisionlabs/guide-mui'
361
+
362
+ const welcomeTour: Tour = {
363
+ id: 'welcome',
364
+ steps: [
365
+ { target: 'projects.create', title: 'Create a project', body: 'Start here.' },
366
+ { target: 'project.share', title: 'Share it', body: 'Send a link to your team.' },
367
+ ],
368
+ }
369
+
370
+ const hotspots: Hotspot[] = [
371
+ {
372
+ id: 'create',
373
+ target: 'projects.create',
374
+ title: 'Start a project',
375
+ body: 'Everything else in here hangs off a project.',
376
+ },
377
+ {
378
+ id: 'share',
379
+ target: 'project.share',
380
+ title: 'Share a project',
381
+ body: 'Send a link to anyone on your team.',
382
+ // Named tours must exist on the GuideProvider above, or starting one warns and does nothing.
383
+ tourId: 'welcome',
384
+ },
385
+ ]
386
+
387
+ function App() {
388
+ return (
389
+ <GuideProvider tours={[welcomeTour]}>
390
+ <HotspotProvider hotspots={hotspots}>
391
+ <YourApplication />
392
+ <GuideTour />
393
+ <Hotspots />
394
+ </HotspotProvider>
395
+ </GuideProvider>
396
+ )
397
+ }
398
+ ```
399
+
400
+ Without a `GuideProvider` above it, starting a hotspot's tour warns once in the console and does
401
+ nothing.
402
+
403
+ ### `HotspotProvider` props
404
+
405
+ | Prop | Type | Default | Description |
406
+ | --- | --- | --- | --- |
407
+ | `hotspots` | `Hotspot[]` | none | The hotspots to render. Ids must be unique. |
408
+ | `children` | `ReactNode` | none | Your application. |
409
+ | `storage` | `GuideStorage` | none | Persists which hotspots have been opened, under `hotspots:seen`. See "Persistence". |
410
+ | `translate` | `(key: string) => string` | none | Resolves `titleKey` / `bodyKey` on hotspots. See "Translations". |
411
+ | `onEvent` | `(event: GuideEvent) => void` | none | Called for `hotspot:show` and `hotspot:open`. See "Events". |
412
+
413
+ ### `useHotspots()`
414
+
415
+ Returns `{ hotspots, restored, open, startTour, reset, notifyShown }`.
416
+
417
+ - `hotspots` is `ResolvedHotspot[]`: every hotspot, each carrying its own `seen`, with `title` /
418
+ `body` already resolved through `translate`. It lists the seen ones too, rather than only the
419
+ unseen ones, because a renderer that keeps a marker mounted while its own bubble closes needs
420
+ the seen one as well; filtering to unseen-only is one line at the call site.
421
+ - `restored` is whether the initial read from storage has settled: `true` immediately with no
422
+ `storage` prop (there is nothing to wait for), and `true` once the read resolves or rejects.
423
+ Wait for it before drawing any marker, or a hotspot already seen in storage can flash on screen
424
+ once before the restore lands.
425
+ - `open(hotspotId)` marks a hotspot seen and emits `hotspot:open`.
426
+ - `startTour(hotspotId)` starts the tour named by the hotspot's `tourId`, if it has one.
427
+ - `reset()` clears the seen state for every hotspot.
428
+ - `notifyShown(hotspotId)` is for renderers: call it once a marker is actually drawn on screen, so
429
+ `hotspot:show` fires once per hotspot per mount. `@apollovisionlabs/guide-mui`'s `Hotspots`
430
+ already calls it.
431
+
432
+ ### `Hotspots` (`@apollovisionlabs/guide-mui`)
433
+
434
+ Renders a marker at each unseen hotspot's target; clicking it opens a bubble with the hotspot's
435
+ title, body, and, when it names a `tourId`, a button that starts that tour.
436
+
437
+ | Prop | Type | Default | Description |
438
+ | --- | --- | --- | --- |
439
+ | `labels` | `Partial<{ marker, startTour, close }>` | see below | Wording. `marker` is a function of the hotspot's title, not a fixed string, because word order around a name varies by language. |
440
+ | `placement` | `Placement` | `'bottom'` | Where the bubble opens relative to the marker. Overridable per hotspot through `Hotspot.placement`. |
441
+ | `zIndex` | `number` | `theme.zIndex.drawer + 1` | Stacking level of the marker; the bubble sits one above it. |
442
+
443
+ Default labels: `` { marker: (title) => `Show what is new: ${title}`, startTour: 'Show me', close: 'Close' } ``.
444
+
445
+ No marker is drawn while a tour is running or paused. A hotspot is an ambient hint and must not
446
+ compete with a guided flow the user is already in: a marker over the element a step points at
447
+ would take the click meant for that step, and one over a non-interactive step would be drawn
448
+ bright and pulsing yet inert behind the spotlight. The markers come back when the tour ends,
449
+ unchanged: this suppresses them, it does not mark them seen. `Hotspots` reads the tour state
450
+ through context and tolerates its absence, so hotspots work with no `GuideProvider` in the tree.
451
+
452
+ `paused` counts, because a paused tour is waiting for its target rather than finished. Note what
453
+ that implies at the edge: a tour paused on a target that never appears, the default `wait`
454
+ policy, draws nothing itself and now hides every hotspot too, for as long as it stays paused,
455
+ with `Escape` as the only way out and nothing on screen to suggest it. If your steps point at
456
+ targets that may never mount, prefer the `skip` or `error` missing-target policy over `wait`.
457
+
458
+ The default `zIndex` sits below `theme.zIndex.modal`, the level a running tour's spotlight uses,
459
+ so a hotspot whose target lives inside your own modal dialog is covered by it. Raise `zIndex` on
460
+ `Hotspots` to bring the marker above that dialog.
461
+
462
+ A marker is drawn only for a target that has actual size on screen. An element that is in the DOM
463
+ but not rendered, `display: none` for instance, measures an all-zero rectangle; that draws no
464
+ marker and emits no `hotspot:show`, so a hotspot cannot be retired before the user has seen what
465
+ it explains.
466
+
467
+ Clicking a marker whose bubble is already open closes the bubble, and emits no second
468
+ `hotspot:open`.
469
+
470
+ With a `storage` prop configured on `HotspotProvider`, `Hotspots` waits for the initial restore to
471
+ settle (`useHotspots().restored`) before drawing any marker.
472
+
193
473
  ## Compatibility
194
474
 
195
475
  | | Supported |