Skip to content

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

PieceFileRole
Overlay componentionic_frontend/src/components/tour/FeatureTour.vueRenders 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.
Composableionic_frontend/src/composables/useFeatureTour.tsuseFeatureTour(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.
Analyticsionic_frontend/src/lib/tourAnalytics.tstrackTourEvent() — 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.
Storybookionic_frontend/src/components/tour/FeatureTour.stories.tsIntro, 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:

  1. Intro (dialog) — "Welcome to the good part."
  2. Streak (coach-mark, anchor streak)
  3. Check-in (coach-mark, anchor check-in)
  4. Coach (coach-mark, anchor coach)
  5. Today's actions (coach-mark, anchor today-actions)
  6. Goals (coach-mark, anchor goals)
  7. Nav (coach-mark, anchor nav — set on SideMenu/BottomTabBar in App.vue)
  8. 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 idSurface / viewSteps fileCoach-mark anchors
goals-tourGoals.vue (/goals)goalsTourSteps.tsgoals-new, goals-stats, goals-tabs
coach-tourCoach.vue (/coach)coachTourSteps.tscoach-synopsis, coach-chat, coach-persona
communities-tourCommunities.vue (/communities)communitiesTourSteps.tscommunities-create, communities-hub, communities-allies, communities-explore
achievements-tourAchievements.vue (/achievements)achievementsTourSteps.tsach-hero, ach-next, ach-showcase
feed-tourActivityFeed.vue (/feed)feedTourSteps.tsfeed-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 via deep_merge('feature_tours' => { tour_id => patch }). The patch always includes all three keys (explicit nil where 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 — a JSON scalar, resolves object.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:

  1. Synchronously resets the tour's entry in Apollo's normalized cacheclient.cache.modify on the User:<publicId> entity's featureTourStatus field, setting { version: 1, completed_at: null, dismissed: null } for just that tourId (sibling tours in the same JSON blob are untouched). This is what makes Replay actually work: the destination surface's own FEATURE_TOUR_STATUS_QUERY is a cache-first query against the same normalized entity, so without this step it would read the still-completed/dismissed cache entry the instant it mounts, and useFeatureTour's start() guard (active.value || isComplete.value) would silently no-op.
  2. Fires storeFeatureTourState({ tourId }) (fire-and-forget, .catch(() => {})) to reset the same entry server-side. Its useMutation also carries an update(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 synchronous cache.modify above is what the UI actually depends on.
  3. Clears the tour's local dismiss mirror (clearDismissed(dismissKey(tourId)), same useDismissedActions key useFeatureTour itself writes on skip/complete) — the local half of the same staleness problem, since useFeatureTour's isComplete computed is server 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 a role="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 9999px at 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 native scrollIntoView({ behavior: 'smooth' }) rather than a literal duration/curve — the same idiom Dashboard.vue already uses elsewhere.
  • The Skip/Back/Next controls go pointer-events-none + aria-disabled="true" for the duration of a travel, released on the tooltip's transitionend — 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.

PieceFileRole
Registryionic_frontend/src/composables/whatsNewRegistry.tsWHATS_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.
Composableionic_frontend/src/composables/useWhatsNew.tsuseWhatsNew() — resolves the single eligible feature (if any), tracks activeFeature/mode, and persists engage/dismiss.
Watcherionic_frontend/src/components/tour/WhatsNewWatcher.vueMounted 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.
Calloutionic_frontend/src/components/tour/WhatsNewSpotlight.vueRenders 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 flagwhats_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:

  1. whats_new_enabled is on.
  2. The user is past first-rundashboard-first-run is complete or dismissed. A user still mid-onboarding never sees a What's New callout; they get the first-run tour instead.
  3. Version-aware: the feature's minVersion is 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 generic storeFeatureTourState mutation, seeded once via persistEpoch() the instant dashboard-first-run completes (or on first read, for an account that finished first-run before What's New shipped).
  4. Unseen: no whatsnew:<featureId> entry with completed_at or dismissed set.
  5. 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) scans UserDetail.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, nil when started is zero). Returns aggregate counts only, never user identifiers.
  • started is a lower-bound proxy, not a true start count. data['feature_tours'] only ever records a terminal outcome (completed_at or dismissed) — useFeatureTour's start() fires a tour_started PostHog event but never calls the persistence mutation. So started here is computed as completed + 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 from tourAnalytics.ts's tour_started/tour_completed events.
  • GraphQL: Query.tourFunnelStats(periodSeconds: Int): [TourFunnelType!] (rails_api/app/graphql/types/query_type.rb, resolver in rails_api/app/graphql/resolvers/admin_queries.rb). require_admin!-gated, same as the existing funnelStats (onboarding) query it sits alongside.
  • No admin_dashboard UI view yet. Unlike the onboarding funnel (admin_dashboard/src/views/OnboardingFunnelView.vue), tourFunnelStats isn't wired into an admin_dashboard page 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.

Last updated: 2026-08-11

Loading…