Feature Tour — Guided Product Tour Engine
A reusable, spotlight-and-coachmark walkthrough engine, gated behind
feature_tour_enabled. Ships as a flagship first-run dashboard tour plus five per-surface mini-tours, each fired at most once per user per surface — and replayable any time from Settings → Tours.
Overview
FeatureTour is a generic overlay component (ionic_frontend/src/components/tour/FeatureTour.vue) paired with a composable (ionic_frontend/src/composables/useFeatureTour.ts) that any surface can use to walk a user through a sequence of steps — a centered intro/outro dialog, or a spotlighted "coach-mark" tooltip anchored to a live element on the page. Completion/dismissal state is server-authoritative, so a tour never re-fires across devices once a user has finished or skipped it.
Phase 1 shipped the dashboard first-run tour (ionic_frontend/src/views/Dashboard.vue), tour id dashboard-first-run — an 8-step walkthrough (intro dialog, 6 coach-mark stops, outro dialog) that fires the first time a new user lands on /dashboard?source=wizard straight out of the onboarding wizard. See Onboarding Timeline — Day 1 for where this sits in the first-session flow.
Phase 2 extended the same engine — unchanged — to five more surfaces (Goals, Coach, Communities, Achievements, Activity Feed) and added a Settings → Tours section so a user can replay any of the six tours on demand. See The five surface tours and Replay from Settings below.
Phase 3 added two things that don't touch the tour engine itself: a lighter-weight, non-blocking What's New spotlight for re-engaging existing users about a newly-shipped feature, and an admin-only tour completion funnel query. See The What's New spotlight and Tour completion funnel below.
Engine
| Piece | File | Role |
|---|---|---|
| Overlay component | ionic_frontend/src/components/tour/FeatureTour.vue | Renders the backdrop, spotlight, coach-mark tooltip, and intro/outro dialogs. Positions the coach-mark via @floating-ui/dom (flip + shift collision handling). Takes steps + v-model:active; emits start / skip / complete. |
| Composable | ionic_frontend/src/composables/useFeatureTour.ts | useFeatureTour(tourId, steps) — resolves eligible steps (drops a coach-mark step whose anchor isn't in the DOM, or whose flag is off), tracks active/isComplete, and persists completion/dismissal via the mutation below. |
| Analytics | ionic_frontend/src/lib/tourAnalytics.ts | trackTourEvent() — fire-and-forget PostHog capture (tour_started, tour_skipped, tour_completed; a tour_step_viewed event name is defined but not currently fired). No-ops in dev. A rejected posthog.capture logs a Sentry breadcrumb (category tour) instead of throwing. |
| Storybook | ionic_frontend/src/components/tour/FeatureTour.stories.ts | Intro, CoachmarkStep, Outro, CenterFallback, DarkMode, ReducedMotion variants. |
A step is defined by the FeatureTourStep interface (useFeatureTour.ts): id, layout ('dialog' or 'coachmark'), title, body, and for coach-mark steps an anchor (matches a data-tour="<anchor>" element) plus a preferred placement. An optional flag drops the step entirely unless that feature flag is enabled — infrastructure for future tours to gate individual steps, not exercised by the dashboard tour today (none of its 6 anchors are themselves flag-gated).
Step filtering & the center-fallback
Before a tour opens, resolveEligibleSteps() drops any coach-mark step whose [data-tour="<anchor>"] element isn't in the DOM, or whose flag is off. If every coach-mark step drops out, the tour falls back to intro/outro dialog-only steps — it never opens with zero content. If an anchor that passed the pre-start filter later vanishes at runtime (e.g. a layout race), FeatureTour.vue's effectiveLayout degrades that single step to a centered dialog instead of spotlighting nothing.
The dashboard tour
Defined inline in Dashboard.vue (DASHBOARD_TOUR_STEPS), tour id dashboard-first-run:
- Intro (dialog) — "Welcome to the good part."
- Streak (coach-mark, anchor
streak) - Check-in (coach-mark, anchor
check-in) - Coach (coach-mark, anchor
coach) - Today's actions (coach-mark, anchor
today-actions) - Goals (coach-mark, anchor
goals) - Nav (coach-mark, anchor
nav— set onSideMenu/BottomTabBarinApp.vue) - Outro (dialog) — "You know your way around." Primary action ("Mark today done") checks in the user's first active goal if one exists, otherwise scrolls/focuses the check-in card.
Trigger: a watch on [userResult.value?.user, goalsLoading.value] fires once (dashboardTourAttempted guard) when feature_tour_enabled is on, route.query.source === 'wizard', the user and goals have loaded, and the tour isn't already complete. This is reached from the onboarding wizard's DoneSlide.vue → /dashboard?source=wizard redirect — see Onboarding Timeline — First Session Flow.
The five surface tours
Added in Phase 2 (v4.16). Each is a short, ordered FeatureTourStep[] array defined in its own file under ionic_frontend/src/composables/tours/, with an intro dialog, 2–4 coach-mark stops, and no outro dialog (the last coach-mark step's own primary control reads "Done").
| Tour id | Surface / view | Steps file | Coach-mark anchors |
|---|---|---|---|
goals-tour | Goals.vue (/goals) | goalsTourSteps.ts | goals-new, goals-stats, goals-tabs |
coach-tour | Coach.vue (/coach) | coachTourSteps.ts | coach-synopsis, coach-chat, coach-persona |
communities-tour | Communities.vue (/communities) | communitiesTourSteps.ts | communities-create, communities-hub, communities-allies, communities-explore |
achievements-tour | Achievements.vue (/achievements) | achievementsTourSteps.ts | ach-hero, ach-next, ach-showcase |
feed-tour | ActivityFeed.vue (/feed) | feedTourSteps.ts | feed-filters, feed-list |
communities-tour's communities-allies step carries flag: 'ally-request-flow-enabled' — it drops out of the sequence entirely (not just its anchor) when that flag is off, exercising the engine's per-step flag filtering described above. None of the other four tours gate a step on a flag.
Trigger (per surface): each view declares its own module-scoped <surface>FirstVisitAttempted boolean and <surface>LastHandledTourQuery string, and a watch (immediate) over route.query.tour — for Achievements.vue/Communities.vue/Goals.vue, the watch also includes the view's own loading/error state and no-ops until both have settled; Coach.vue/ActivityFeed.vue have no such data-loading gate, since their anchors are stable as soon as the template mounts. On each fire, isDeepLink is true when route.query.tour equals this tour's id and differs from the last-handled value; a non-deep-link fire is skipped once <surface>FirstVisitAttempted is already true, and the flag is set unconditionally (deep-link or not) once the guard passes — the same shape Dashboard.vue's dashboardTourAttempted uses, so a later reactive re-fire with the same ?tour= still in the URL can't call start() a second time. This logic is copy-pasted per view, not shared through a composable — each of the five files owns its own copy of the watcher.
Deep link: navigating to <route>?tour=<tourId> (e.g. /goals?tour=goals-tour) starts that surface's tour on arrival, whether or not it was already attempted this session. This is how Replay from Settings re-triggers a tour without a page reload.
Persistence — server-authoritative, no migration
Completion/dismissal state lives in UserDetail.data['feature_tours'], a store_accessor on the existing JSONB data column (rails_api/app/models/user_detail.rb) — no schema migration. Each tour id maps to { version, completed_at, dismissed }.
- Interaction:
UserIdentity::StoreFeatureTourState(rails_api/app/interactions/user_identity/store_feature_tour_state.rb) writes viadeep_merge('feature_tours' => { tour_id => patch }). The patch always includes all three keys (explicitnilwhere unset) so a rewrite fully overwrites that tour id's entry rather than leaking a stale key from a prior write — sibling keys (data['onboarding'],data['coach_*'], etc.) are untouched. - Mutation:
storeFeatureTourState(tourId: String!, completed: Boolean, dismissed: Boolean)→{ success, featureTours, errors }(Mutations::StoreFeatureTourState). Requires auth (require_auth!). - Query field:
User.featureTourStatus— aJSONscalar, resolvesobject.details&.data&.dig('feature_tours') || {}(rails_api/app/graphql/types/user_type.rb). Because it's a JSON blob and not a declared GraphQL field, its keys stay exactly as the backend wrote them (snake_case), not camelCased by GraphQL.
On the frontend, useFeatureTour also mirrors the outcome into localStorage (via useDismissedActions) synchronously, before the mutation resolves — so a tour a user just finished or skipped won't re-fire later in the same session even if the network write is still in flight or fails. The server write is fire-and-forget; a failure is swallowed, since the local mirror already recorded the outcome for this session.
Replay from Settings
Added in Phase 2. A Tours tab on Settings.vue (flag-gated on feature_tour_enabled, ionic_frontend/src/components/settings/ToursSettingsSection.vue) lists all six tours — the dashboard first-run tour plus the five surface tours above — each showing a Completed / Skipped / Not started chip and a Start (not-started) or Replay (completed/skipped) button.
The list is driven by TOUR_REGISTRY (ionic_frontend/src/composables/tourRegistry.ts), a { tourId, title, description, route } array that is the single canonical source for the Settings row order and copy. Each of the five surface views declares its own tour id as a literal string in its useFeatureTour(tourId, …) call and its route.query.tour comparison (see The five surface tours) — matching them to TOUR_REGISTRY's tourId values is what makes a registry row and a surface's own tour resolve to the same persisted state; nothing enforces that link beyond the two lists agreeing.
Replay contract (replayOrStart() in ToursSettingsSection.vue): clicking Start/Replay does three things before navigating:
- Synchronously resets the tour's entry in Apollo's normalized cache —
client.cache.modifyon theUser:<publicId>entity'sfeatureTourStatusfield, setting{ version: 1, completed_at: null, dismissed: null }for just thattourId(sibling tours in the same JSON blob are untouched). This is what makes Replay actually work: the destination surface's ownFEATURE_TOUR_STATUS_QUERYis acache-firstquery against the same normalized entity, so without this step it would read the still-completed/dismissed cache entry the instant it mounts, anduseFeatureTour'sstart()guard (active.value || isComplete.value) would silently no-op. - Fires
storeFeatureTourState({ tourId })(fire-and-forget,.catch(() => {})) to reset the same entry server-side. ItsuseMutationalso carries anupdate(cache, { data })callback that writes the mutation's own response into the same cache field, as defense-in-depth for once the network round-trip lands — but the synchronouscache.modifyabove is what the UI actually depends on. - Clears the tour's local dismiss mirror (
clearDismissed(dismissKey(tourId)), sameuseDismissedActionskeyuseFeatureTouritself writes on skip/complete) — the local half of the same staleness problem, sinceuseFeatureTour'sisCompletecomputed isserver state OR local dismiss mirror.
Then it navigates to <tour.route>?tour=<tourId>, which the destination surface's deep-link watcher (above) picks up and starts immediately.
Known follow-up, still open: useFeatureTour's queryLoading (the server-status query's own loading flag) still isn't consumed by any start-gate, on the dashboard or the five Phase 2 surfaces. This remains safe today because Replay's correctness comes from the synchronous cache-modify step above, not from surfaces waiting on a fresh server read — but it's worth keeping in mind if a future change makes a surface's tour-start logic depend on queryLoading resolving first.
Feature flag
feature_tour_enabled (PostHog, registered in ionic_frontend/src/lib/featureFlags.ts). Disabled with 0% rollout by default. When off, FeatureTour never mounts on the dashboard and the trigger watch short-circuits. Gates the dashboard tour, the five surface tours, and the Settings → Tours replay list. The What's New spotlight uses its own separate whats_new_enabled flag (same file) — see The What's New spotlight. See Feature Flags for the flag lifecycle (introduce → rollout → GA → retire).
Accessibility & motion
- Both the coach-mark tooltip and the dialog surfaces use
role="dialog"+aria-modal="true"; the coach-mark step also carries arole="progressbar"announcing "Step n of total". - Focus is trapped inside the active surface (custom Tab-trap, no external dependency) and returned to the just-highlighted anchor on exit (or the page's
<h1>for a dialog step, or whatever was focused before the tour opened). Esc, a backdrop click, or the Skip button all dismiss immediately with no confirmation step — a tour is always skippable, never a dark pattern.- All motion is gated on
useReducedMotion(). Reduced motion is opacity-only (≤120ms): the spotlight, halo, and tooltip reposition instantly with no travel animation, the halo doesn't breathe, the badge doesn't spring, and the completion ring never renders at all. - Coachmark mode's ink comes from the spotlight cut-out's own
box-shadow(.ft-cutout,0 0 0 9999pxat the same ~62%-dark value the dialog scrim uses) rather than a backdrop-scrim div — the anchor sits inside a genuinely transparent window while the rest of the viewport stays inked. Dialog mode (intro/outro/center-fallback) is unchanged: a flat full-viewport scrim +backdrop-blur-sm. - Stepping between coach-mark stops travels the cut-out, halo, and tooltip together over 460ms (
cubic-bezier(.4,0,.2,1)) as one persistent set of elements — the tooltip no longer unmounts/remounts between steps (no keyed<Transition mode="out-in">); its copy cross-fades out over the first 180ms and in over the next 180ms instead. The arrow offset interpolates on the same curve and never swaps sides mid-travel. The page also scrolls the anchor toward center, via the browser's nativescrollIntoView({ behavior: 'smooth' })rather than a literal duration/curve — the same idiomDashboard.vuealready uses elsewhere. - The Skip/Back/Next controls go
pointer-events-none+aria-disabled="true"for the duration of a travel, released on the tooltip'stransitionend— never a fixed timer, since the global reduced-motion reset collapses transitions to near-zero. This closes an input-lock gap the persistent tooltip introduced: without it, the Next button stayed mounted and clickable while the copy still read the previous step. - Progress segments fill left-to-right (420ms each, staggered 40ms apart) instead of snapping between solid/muted fills; the currently-filling segment carries the brand gradient.
- Completion still pops a gold badge (400ms overshoot curve), now 56px (up from 48px), plus one expanding gold ring (700ms,
ease-out) that fires once and dies. The ring is suppressed — badge still pops alone — if another celebration overlay (LegendaryCelebration.vue, an achievement toast) is already mounted, so a user never sees two gold moments at once. - Every interactive control (Skip, Back, Next/Done) uses a
min-h-[44px]tap target in both layout modes.
Visual design — the opaque bg-card deviation
Tour surfaces (coach-mark tooltip and dialogs) deliberately use an opaque bg-card rather than the standard .glass-card glassmorphism recipe (see Style Guide § Glassmorphism). Full rationale is in the Style Guide's tour-overlay note: the translucent glass recipe over the tour's ~62%-dark ink dropped dark-mode body copy to roughly 1.9:1 contrast, and the light-only gradient card overlay washed out grey in dark mode. An opaque surface plus shadow-2xl keeps both themes AA-clean.
Dialog mode renders that ink as a flat full-viewport scrim div. Coachmark mode no longer does — there's no full-viewport dim there, only the spotlight cut-out's own box-shadow, which inks everywhere outside its transparent window at the same ~62%-dark value (see Accessibility & motion above). The tooltip sits outside that window, so the contrast rationale carries over unchanged: same ink value, different element producing it.
Building another tour
FeatureTour/useFeatureTour are generic — a new tour needs a unique tourId, a FeatureTourStep[] array, and data-tour="<anchor>" attributes on the elements each coach-mark step targets, plus a matching entry in TOUR_REGISTRY (tourRegistry.ts) if it should also be replayable from Settings. Nothing about the engine, mutation, or persistence model is surface-specific — the six shipped tours (one dashboard, five Phase 2 surfaces) all reuse it unchanged. Follow The five surface tours for the per-view watcher shape (auto-start-once + ?tour= deep-link) a new surface should mirror.
Goals.vue, Coach.vue, Communities.vue, Achievements.vue, and ActivityFeed.vue all now have a dedicated "guided tour watcher" test suite covering auto-start-once, the deep-link re-arm, and the no-double-fire case (tests/unit/views/*.render.spec.ts) — TOUR-QA-3 backfilled the four surfaces that shipped in Phase 2 without one.
The What's New spotlight (re-engagement)
Added in Phase 3 (TOUR-WHATSNEW-3). A lighter-weight, non-blocking sibling to the coach-mark tours above: a single dismissible callout that re-introduces one newly-shipped feature to an existing user, rather than walking a new user through a whole surface. Full design contract: .planning/phases/v4.16-guided-product-tour-phase-3/UI-SPEC.md.
What's reused vs. new: the callout is its own component (WhatsNewSpotlight.vue) — it does not render through FeatureTour.vue. What it reuses from Phase 1 is the persistence layer (storeFeatureTourState + useDismissedActions) and the analytics helper (tourAnalytics.ts), keyed by feature id instead of tour id.
| Piece | File | Role |
|---|---|---|
| Registry | ionic_frontend/src/composables/whatsNewRegistry.ts | WHATS_NEW_FEATURES: WhatsNewFeature[] — one entry per spotlight-worthy feature: featureId, minVersion, title, body, cta, route, optional anchor/placement. Ships empty — no feature has added an entry yet, so the spotlight is wired but inert in production today. |
| Composable | ionic_frontend/src/composables/useWhatsNew.ts | useWhatsNew() — resolves the single eligible feature (if any), tracks activeFeature/mode, and persists engage/dismiss. |
| Watcher | ionic_frontend/src/components/tour/WhatsNewWatcher.vue | Mounted once, globally, in App.vue — renders WhatsNewSpotlight when useWhatsNew() has an eligible feature. Unlike the six tours above, this isn't per-surface; it can fire on any route. |
| Callout | ionic_frontend/src/components/tour/WhatsNewSpotlight.vue | Renders Mode A (anchored — soft halo + pulse ring + arrow-pointed card, no scrim) when feature.anchor resolves a live [data-whatsnew="<anchor>"] element, or Mode B (docked — corner/bottom-bar card) otherwise. Falls back from A to B at render if the anchor vanishes (anchor-fallback emit). |
| Feature flag | whats_new_enabled (ionic_frontend/src/lib/featureFlags.ts) | Dedicated flag, separate from feature_tour_enabled — the UI-SPEC's recommended choice (independent ops kill-switch for re-engagement callouts vs. the guided tours), and what Codi shipped. |
Eligibility (isEligible() in useWhatsNew.ts), in order:
whats_new_enabledis on.- The user is past first-run —
dashboard-first-runis complete or dismissed. A user still mid-onboarding never sees a What's New callout; they get the first-run tour instead. - Version-aware: the feature's
minVersionis newer than the user's What's New epoch — the app version the user had already reached before What's New started evaluating for them. The epoch has no dedicated backend field; it's encoded as a synthetic tour id (whatsnew-epoch:<version>) written through the same genericstoreFeatureTourStatemutation, seeded once viapersistEpoch()the instantdashboard-first-runcompletes (or on first read, for an account that finished first-run before What's New shipped). - Unseen: no
whatsnew:<featureId>entry withcompleted_atordismissedset. - Not locally dismissed this session (
useDismissedActions).
Only the first eligible entry in WHATS_NEW_FEATURES order is shown, and evaluate() enforces at most one callout per session (shownThisSessionForUserId, scoped to the current account so an account switch mid-session doesn't suppress a spotlight the new account hasn't seen).
Lifecycle: on render, fires whatsnew_shown. The primary CTA (engage()) persists completed: true, fires whatsnew_completed, and navigates to feature.route. Dismiss — ×, "Not now", or Esc (dismiss()) — persists dismissed: true and fires whatsnew_dismissed. Both are terminal; neither re-fires, matching the complete()/skip() shape the six coach-mark tours already use.
Non-blocking deviation from the Phase 1 overlay contract: no backdrop scrim. Ring/halo are pointer-events-none, so only the card itself captures input — the app underneath stays fully interactive. The UI-SPEC's rationale: a first-run tour wants a new user's full attention; a re-engagement nudge mid-session for an existing user does not, and hijacking the screen for it would border on a dark pattern.
Adding a spotlight: add an entry to WHATS_NEW_FEATURES in the same PR that ships the user-facing feature you want to spotlight (order is the tie-break priority when more than one is newly eligible — highest first). Also add a bullet to ionic_frontend/CHANGELOG.md's [Unreleased] section, same as any other user-facing change.
Tour completion funnel
Added in Phase 3 (TOUR-FUNNEL-3), admin/analytics only — no user-facing surface.
- Interaction:
Admin::ComputeTourFunnel(rails_api/app/interactions/admin/compute_tour_funnel.rb) scansUserDetail.data['feature_tours']for users created within a window (period_seconds, default 30 days) and returns per-tour-id counts:started,completed,dismissed,completion_rate(completed / started,nilwhenstartedis zero). Returns aggregate counts only, never user identifiers. startedis a lower-bound proxy, not a true start count.data['feature_tours']only ever records a terminal outcome (completed_atordismissed) —useFeatureTour'sstart()fires atour_startedPostHog event but never calls the persistence mutation. Sostartedhere is computed ascompleted + dismissed: reaching either outcome implies a start happened, but a user who started and abandoned mid-tour with neither outcome leaves no trace in this query. The true start-inclusive funnel lives in the PostHog "Tour Funnel" dashboard, built fromtourAnalytics.ts'stour_started/tour_completedevents.- GraphQL:
Query.tourFunnelStats(periodSeconds: Int): [TourFunnelType!](rails_api/app/graphql/types/query_type.rb, resolver inrails_api/app/graphql/resolvers/admin_queries.rb).require_admin!-gated, same as the existingfunnelStats(onboarding) query it sits alongside. - No admin_dashboard UI view yet. Unlike the onboarding funnel (
admin_dashboard/src/views/OnboardingFunnelView.vue),tourFunnelStatsisn't wired into anadmin_dashboardpage as of this ship — it's queryable today, but visualizing it is a follow-up if/when the tour funnel needs its own admin page rather than ad hoc queries + PostHog.
Related
- Onboarding Timeline — where the dashboard tour fires in the Day-1 arc
- Dashboard — the four dashboard render branches the tour's anchors live across
- User Guide — Settings, profile, and your data — the user-facing Tours replay list
- Feature Flags — flag lifecycle and rollout mechanics
Last updated: 2026-08-11