Skip to main content

Walkthroughs & spotlight tours

The apps have two guidance systems. Pick by what the user should look at:

  • Modal walkthroughs (packages/ui/src/walkthrough.tsxWalkthroughModal / WalkthroughCarousel): a centered scrim modal that narrates a mock illustration. Use for explaining a concept or a flow the user isn't currently looking at (chart types, roster upload, exercise creation).
  • Spotlight tours (packages/ui/src/tour/): dim the real screen, cut a hole around a live element, float a step card beside it (driver.js-style). Use for orienting a user on an actual surface — "this is the sidebar, these are your stats". This page documents these.

How a spotlight tour works

  • EngineTourProvider (packages/ui/src/tour/tour-provider.tsx) is mounted once per app shell (portal: apps/portal/src/app/dashboard/layout.tsx; tracking: apps/tracking/src/app/layout.tsx). Pages start tours via useTourController().start(tour) or declaratively with <TourAutoStart tour={...} />.
  • Targets — a step points at an element by its data-tour="<id>" attribute. A missing or invisible target (permission-gated nav item, collapsed mobile sidebar) silently skips that step — a tour gets shorter, it never breaks or blocks.
  • Completion state — stored per account on the backend and nowhere else, so a tour finished on one device is finished everywhere; packages/core/src/tours/store.ts owns it. A tour that was finished or dismissed never auto-starts again — there is no versioning, so materially new content means a new tour id. See product-guidance-backend.md.
  • Skip all / reactivation — every step card carries a "Skip all tours" link that switches the global opt-out on immediately: no tour auto-starts while it's set. The portal's settings drawer mirrors it as a Product tours → Show tours switch near its bottom (apps/portal/src/components/settings-tours-section.tsx, fed into the shared drawer's extraSections slot). Like every other row there it is buffered: the change applies on Save (via the drawer's onSaveExtras seam) and is dropped if the drawer is closed without one. Switching it back on calls resetAllTours(), so every tour greets the user again; the drawer closes on save, which also keeps the re-armed tour from starting behind it. There are no per-page replay buttons.
  • The page behind a running tour is inert (clicks and scrolling blocked); Escape or the X dismisses.
  • Oversized targets (a region taller/wider than the viewport — mobile stacks, long tables): the hole is clamped to the viewport with the scrim edge and accent ring kept visible, and scrolling unlocks for that step — wheel/touch deltas are proxied into the target's own scroll pane and the hole tracks. Authors never need mobile-specific targets for size reasons.

Authoring a tour (recipe)

  1. Declare targets in the app's registry — apps/portal/src/tours/tour-targets.ts — and put data-tour={TOUR_TARGET.myThing} on the elements. Every anchor the app exposes lives in this one greppable file so tours and components can't drift apart.
  2. Define the tour as a hook next to the others (apps/portal/src/tours/use-welcome-tour.ts is the template): return a TourDefinition ({ id, steps }) from useMemo, with every title/body written as a literal t("...") call — t(someVar) is silently skipped by i18n extraction (see i18n). Ask the backend team to seed a product_guidance_items row for the new id — without it the tour works, but its state stays per-browser.
  3. Wire the page: <TourAutoStart tour={useMyTour()} /> on the page that owns the surface. No per-page replay affordance — reactivation is the settings drawer's global "Show tours" switch.
  4. Run npm run i18n:extract and commit the regenerated catalog files.

Steps can prefer a card side with placement: "right" | "left" | "top" | "bottom" (auto-flips when it doesn't fit). Order steps so the ones most likely to be visible come first — the portal welcome tour leads with the dashboard cards because the sidebar is collapsed on mobile.

Click-to-advance steps & chaining tours

A step with advanceOn: "click" makes the spotlit target itself clickable (the shield opens around the hole) and hides the Next button — clicking the target advances the tour, or finishes it as done on the last step, while the target's own onClick runs normally. Write the body copy as an instruction ("Click it now — …").

This is how tours chain across a flow boundary: the planner tour's last step has the user click "Create schedule", which finishes the planner tour and opens the schedule drawer — and the drawer mounts its own <TourAutoStart tour={useScheduleTour()} /> (gated on its open state), so the drawer tour starts on its first open. The chain is nothing special: two separate tours with separate completion records, linked only by the click opening the surface that hosts the next auto-start. That also means the second tour fires on ANY first entry to the surface (a day-cell click, the user drawer), and a replayed first tour does not re-show an already-seen second tour.

The auto-start condition can be narrower than "the surface is open": the user drawer mounts its tour only in create mode ({open && mode === "create" && <TourAutoStart …/>}), so opening a profile to edit never triggers the creation tour.

Tours never start over a required-action surface (the 310–312 redirect dialogs, e.g. the full-screen 311 onboarding): start() bails while action-dialog-store reports one open, and TourAutoStart subscribes to that store so a deferred tour re-arms once the surface resolves. Nothing is marked as seen when a start is suppressed, so the tour simply runs on the next opportunity.

Maintaining tours

  • Copy or step edits: just edit the hook. Users who have seen the tour won't see it again.
  • Re-show to everyone (the tour changed substantially): give it a new id and have that key seeded. There is no version knob — the backend registers tours by key and stores one terminal state each, so re-showing means a new item. (An individual user re-arms everything from the settings switch.)
  • Moving/refactoring UI: an element that carries data-tour keeps the attribute when it moves. If it's deleted, delete the constant and its step too (until then the step is skipped, which is safe but silent).
  • Removing a tour: delete the hook, its <TourAutoStart>/replay wiring, and its target constants. Stale localStorage records are harmless.

Persistence

The persistence seam is the @enode/core/tours/store module boundary — components only use useTourAutoStart / markTour / setToursSkipped / resetAllTours, never the API. State lives on the backend per account and nowhere else; see product-guidance-backend.md for the contract, the seeding requirement and the settings-save trap.