Skip to content

Onboarding Flow — 13-Slide Goal + Coach Wizard

Overview

The onboarding experience is a 13-slide wizard that collects everything needed to create a user's first goal and configure their Coach in a single session. There are no active A/B variants and no branching paths — every user sees the same slide sequence. There is one feature-flag-gated slide (Philosophy, see below); the base flow itself has no gate.

When onboarding_pledge_enabled is on, a 14th slide (Philosophy) is inserted at index 1 — see Slide 1.5 — Philosophy below. The rest of this page describes the 13 slides that always render.

The wizard atomically creates the first goal and stores all onboarding answers via the CompleteOnboardingAndCreateGoal mutation at the end. If the user did not name a goal, the Done slide routes to /goals/create with ?source=wizard query params instead of calling the atomic mutation.

Related docs:


All /welcome/* routes have meta.onboarding: true in the router. App.vue reads this flag to hide:

  • SideMenuv-if="!route.meta.onboarding"
  • BottomTabBarv-if="!route.meta.public && !route.meta.onboarding"
  • CoachDockCard — not rendered on onboarding routes (the Coach dock is a dashboard-only surface, not mounted in App.vue)
  • contentMargin computed — returns 0px during onboarding

Result: full-bleed onboarding canvas with no chrome at any viewport width.


The 13-Slide Flow

Slides are registered in order in ionic_frontend/src/views/welcome/placeholderSlides.ts. The step counter is dynamic (registry.length), so it always reads "STEP N OF 13".

Slide 1 — start (WelcomeIntro.vue)

Intro hero slide. Two-column layout on desktop (≥768px): content glass-card left, ObjectuveLogoAchieved illustration right (hidden on mobile). Renamed from ObjectuveLogoJourney under OBJ-2459 — mechanical rename only, no behaviour change; ObjectuveLogoJourney now names a different component (the launch-interstitial sweep).

Headline: "Let's set the direction."
Body: "Objectuve works better when it knows what you're aiming at. Ten small questions, one real answer each, and you're off."
Section preview list (mobile only): three rows — "1–3 The goal / Area, what, when", "4–5 Your why / Keeps the Coach honest.", "6–10 Your Coach / Persona, tone, focus, rhythm."
CTA: "Let's go →"

hideFooter: true, hideWrapper: true, noBack: true. No answers collected.


Slide 1.5 — philosophy (PhilosophySlide.vue)

Optional, skippable "Our Philosophy" slide (v4.1 Phase 3, OBJ-1147). Registered at index 1 — between start and life-areaonly when the onboarding_pledge_enabled PostHog flag is on; when off, the slide is absent from the registry entirely (not skipped), so the step counter reads "STEP N OF 13" rather than showing a skipped step.

Headline: "This isn't built to keep you here." Body: the shared PHILOSOPHY_TAGLINE — "No infinite feeds. No engagement traps. No dark patterns." Content: the four /our-story values, rendered from the same shared data as the public OurStory.vue page — no copy drift between the two surfaces. CTA: "Sounds good" (footer, chrome-owned). Skip is also chrome-owned — no slide-local skip/advance logic.

No answers collected; validate: () => true. wizard_slide_viewed / _advanced / _skipped analytics fire automatically since the chrome's analytics wiring is slide-id-agnostic.

Flag-consistency requirement: WelcomeView.vue's known-slide check and WelcomeLayout.vue's registry must both read the same flag-filtered registry, or a session that starts before PostHog loads can desync from a later live flag read and loop the /welcome redirect. ionic_frontend/src/views/welcome/placeholderSlides.ts exports getPlaceholderSlides() — a function that re-checks the flag on every call rather than a module-scope constant — for exactly this reason; both consumers call it fresh instead of importing a cached array. resumeResolver.ts doesn't consume the slide registry at all — its resume fallback calls isFeatureFlagEnabled('onboarding_pledge_enabled') directly to reject a cached resume position at the flag-gated philosophy slide when the flag is off, applying the same live-flag-read discipline to the one check it actually needs to make.

Our-story shared content

ionic_frontend/src/constants/ourStoryValues.ts is the single source of truth for the four values (OUR_STORY_VALUES) and the PHILOSOPHY_TAGLINE, imported by both ionic_frontend/src/views/OurStory.vue (the public /our-story marketing page) and PhilosophySlide.vue. A future copy edit to either surface should go through this shared module — editing the values inline in either .vue file would reintroduce drift. OurStoryValue.shortDesc is an optional, slide-only truncation for the one value whose desc runs two sentences; /our-story always renders the full desc.


Slide 2 — life-area (LifeAreaSlide.vue)

User picks the goal's life area / category. The selected value is stored as lifeArea: { id, label } and its id is passed as goalCategoryId in the final mutation.

Validation: requires a selection to advance ("Pick an area to continue.").
Stores: answers.lifeArea


Slide 3 — goal-name (GoalNameSlide.vue)

User writes their goal title in their own words.

Validation: string must be ≥ 3 characters ("Write your goal in your own words.").
Stores: answers.goalName


Slide 4 — goal-shape (GoalShapeSlide.vue)

User picks a goal shape: sprint, arc, habit, or life-list. Sprint and arc shapes additionally require a target date.

Validation: shape required; target date required when shape is sprint or arc.
Stores: answers.goalShape, answers.targetDate


Slide 5 — goal-why (GoalWhySlide.vue)

User writes their motivation for the goal. Optional — validate always returns true.

Stores: answers.goalWhy


Slide 6 — coach-persona (CoachPersonaSlide.vue)

User selects one of six Coach personas. Selection immediately fires UPDATE_COACHING_PREFERENCES_MUTATION to write the choice to the backend without waiting for the final Done mutation.

Personas (from ionic_frontend/src/constants/coachPersonas.ts):

IDNameTraits
captainThe CaptainStructured · decisive
sparkThe SparkWarm · contagious
mirrorThe MirrorReflective · non-directive
rivalThe RivalDirect · competitive
sageThe SageSteady · long-view
analystThe AnalystData-led · precise

Layout: card grid (1 col mobile → 2 col sm → 3 col lg). Tapping a card opens a detail view; "Choose [Name]" in the detail view selects and auto-advances. role="radiogroup" + role="radio" + aria-checked per card.

hideFooter: true, hideWrapper: true.
Validation: persona required ("Tap a Coach to meet them.").
Stores: answers.coachPersona (type PersonaId)


Slide 7 — coach-tone (CoachToneSlide.vue)

User sets tone (brevity + warmth sliders). Optional — validate always returns true.

Stores: answers.coachTone, answers.coachDepth


Slide 8 — coach-focus (CoachFocusSlide.vue)

User picks primary (and optionally secondary) focus areas.

Validation: primary focus area required ("Pick at least one focus area.").
Stores: answers.coachFocusPrimary, answers.coachFocusSecondary


Slide 9 — coach-rhythm (CoachRhythmSlide.vue)

User picks check-in frequency. Optional — validate always returns true.

Stores: answers.coachFrequency


Slide 10 — goal-motivation-snapshot (GoalMotivationSnapshotSlide.vue)

Four optional profiling questions. The slide is unconditional — the goal-motivation-snapshot-enabled PostHog flag was retired at full rollout (Phase 93.1, 2026-05-29). All four questions can be individually skipped.

hideFooter: true, hideWrapper: true. Validate always returns true.

Questions:

#QuestionOption IDs
1"How many goals have you taken on before?"never, a_few_times, many_still_going, many_struggled
2"What time of day do you usually work on your goals?"morning, afternoon, evening, varies
3"What's your biggest challenge with goals?"starting, staying_consistent, momentum_after_setbacks, knowing_progress
4"Are you working on this goal alone or with others?"solo, friend_or_partner, team_or_group

Stores: answers.motivationExperience, answers.motivationWorkTime, answers.motivationChallenge, answers.motivationSocialContext (each string | null; null = explicitly skipped)

Answers persist to UserDetail.data['goal_motivation_profile'] via UPDATE_GOAL_MOTIVATION_SNAPSHOT_MUTATION. Partial saves preserve existing keys (merge semantics).

goal_motivation_profile schema:

FieldTypeValues
experienceString | nullnever, a_few_times, many_still_going, many_struggled
work_timeString | nullmorning, afternoon, evening, varies
challengeString | nullstarting, staying_consistent, momentum_after_setbacks, knowing_progress
social_contextString | nullsolo, friend_or_partner, team_or_group
completed_atString | nullISO 8601 timestamp of last save

Slide 11 — source (SourceSlide.vue)

How the user found Objectuve. Skipped automatically when URL-sourced attribution is already present (UTM params, invite code, ref — detected via hasUrlAttribution()). Otherwise optional (validate: () => true).

Stores: answers.sourceAttribution (a SourceAttribution-shaped object with userReported key)


Slide 12 — review (ReviewSlide.vue)

Confirmation screen. Shows three review rows — Coach persona, first focus (goal category), and source attribution — each with an edit pencil that routes back to the originating slide. skip: () => true in the current registry, meaning this slide is unconditionally skipped.

Note: As of this writing the review slide is registered but always skipped. The component exists and renders correctly; the skip guard can be removed to re-enable it.


Slide 13 — done (DoneSlide.vue)

Celebration screen. Fires confetti on mount (respects prefers-reduced-motion). Displays a summary table (Area, By, Shape, Coach, Voice, Watching, Rhythm) built from flow.answers. Shows the goal name as the headline, or falls back to persona-specific copy from personaSlideCopy.ts.

CTA: "Start your first week →"
hideFooter: true, isTerminal: true.

On submit, two paths:

  1. goalName is present — calls COMPLETE_ONBOARDING_AND_CREATE_GOAL_MUTATION (atomic), then routes to /dashboard?source=wizard.
  2. goalName is absent — calls STORE_ONBOARDING_STATE_MUTATION only, then routes to /goals/create?source=wizard&coachPersona=...&category=....

Slide Registry

From ionic_frontend/src/views/welcome/placeholderSlides.ts. baseSlides is the always-present 13-slide array; getPlaceholderSlides() is the function WelcomeView.vue and WelcomeLayout.vue call to get the flag-filtered registry — it re-checks onboarding_pledge_enabled on every call and splices in philosophy at index 1 when the flag is on. resumeResolver.ts does not call getPlaceholderSlides() — it checks isFeatureFlagEnabled('onboarding_pledge_enabled') directly, since its only job is rejecting a stale cached resume position at philosophy when the flag is off, not resolving the full registry:

ts
const baseSlides: readonly OnboardingSlide[] = [
  { id: 'start',                    component: WelcomeIntro,                nextLabel: "Let's go",            hideFooter: true,  hideWrapper: true, noBack: true  },
  { id: 'life-area',                component: LifeAreaSlide,               nextLabel: 'Continue',                                hideWrapper: true              },
  { id: 'goal-name',                component: GoalNameSlide,               nextLabel: 'Continue',                                hideWrapper: true              },
  { id: 'goal-shape',               component: GoalShapeSlide,              nextLabel: 'Continue',                                hideWrapper: true              },
  { id: 'goal-why',                 component: GoalWhySlide,                nextLabel: 'Continue',                                hideWrapper: true              },
  { id: 'coach-persona',            component: CoachPersonaSlide,           nextLabel: 'Continue',            hideFooter: true,  hideWrapper: true              },
  { id: 'coach-tone',               component: CoachToneSlide,              nextLabel: 'Continue',                                hideWrapper: true              },
  { id: 'coach-focus',              component: CoachFocusSlide,             nextLabel: 'Continue',                                hideWrapper: true              },
  { id: 'coach-rhythm',             component: CoachRhythmSlide,            nextLabel: 'Continue',                                hideWrapper: true              },
  { id: 'goal-motivation-snapshot', component: GoalMotivationSnapshotSlide, nextLabel: 'Continue',            hideFooter: true,  hideWrapper: true              },
  { id: 'source',                   component: SourceSlide,                 nextLabel: 'Continue',                                hideWrapper: true, skip: hasUrlAttribution },
  { id: 'review',                   component: ReviewSlide,                 nextLabel: 'Looks good →',                            hideWrapper: true, skip: () => true },
  { id: 'done',                     component: DoneSlide,                   nextLabel: 'Start your first week', hideFooter: true,                  isTerminal: true },
] as const

export function getPlaceholderSlides(): readonly OnboardingSlide[] {
  return isFeatureFlagEnabled('onboarding_pledge_enabled')
    ? [baseSlides[0], philosophySlide, ...baseSlides.slice(1)]
    : baseSlides
}

Backend: CompleteOnboardingAndCreateGoal Mutation

GraphQL signature (ionic_frontend/src/constants/graphql/user.js):

graphql
mutation CompleteOnboardingAndCreateGoal(
  $goalName: String!
  $goalCategoryId: ID
  $onboardingAnswers: JSON
) {
  completeOnboardingAndCreateGoal(
    goalName: $goalName
    goalCategoryId: $goalCategoryId
    onboardingAnswers: $onboardingAnswers
  ) {
    user { ...UserFragment }
    goal { publicId }
    errors
  }
}

Interaction (rails_api/app/interactions/onboarding/complete_onboarding_and_create_goal.rb):

Wraps two sub-calls in ActiveRecord::Base.transaction:

  1. UserIdentity::StoreOnboardingState.call(...) — persists onboarding_answers JSON blob to the user record, marks onboarding complete
  2. GoalTracking::AddGoal.call(...) — creates the goal with name, goal_category_id (may be nil), private: false

On success returns { user:, goal: }. Either sub-call failure rolls back both and surfaces result.details[:error].

Gamification: create_first_goal badge triggers inside GoalTracking::AddGoal (not in the wrapper interaction directly).


OnboardingAnswers Interface

Defined in ionic_frontend/src/views/welcome/placeholderSlides.ts:

ts
export interface OnboardingAnswers {
  coachPersona?: PersonaId                          // one of the 6 PersonaId values
  coachTone?: 1 | 2 | 3 | 4 | 5
  coachDepth?: 1 | 2 | 3 | 4 | 5
  coachFocusPrimary?: FocusAreaId
  coachFocusSecondary?: FocusAreaId
  coachFrequency?: FrequencyId
  goalCategoryId?: string | null                    // null = explicitly skipped
  sourceAttribution?: SourceAttribution
  lifeArea?: { id: string; label: string } | null
  goalName?: string | null
  goalShape?: 'sprint' | 'arc' | 'habit' | 'life-list' | null
  targetDate?: string | null                        // ISO date e.g. '2026-07-25'
  goalWhy?: string | null
  motivationExperience?: string | null              // null = explicitly skipped
  motivationWorkTime?: string | null
  motivationChallenge?: string | null
  motivationSocialContext?: string | null
  [key: string]: unknown
}

A/B Variant History

The onboarding wizard has no active A/B variants. Two flags were retired and archived in PostHog:

FlagRetiredNotes
new-onboarding-v1-8v1.10Gate flag — wizard reached 100% rollout
new-onboarding-v1-8-variantv1.11Multivariate (control/variant_a/variant_b) — the goal-builder flow it once branched on became universal in Phase E, leaving variants as identical analytics buckets. resolveVariant / getVariantId / variantId removed from useOnboardingFlow and every callsite.
goal-motivation-snapshot-enabledPhase 93.1 (2026-05-29)Gate flag — motivation snapshot slide is now unconditional

There is ONE onboarding flow, always 13 slides. useOnboardingGate.ts gates on !onboardingCompletedAt only — no flag check. The one currently-active flag, onboarding_pledge_enabled (v4.1 Phase 3), is a slide-insertion gate rather than a flow branch — it adds a 14th slide when on, per Slide 1.5 — Philosophy.


Flow State & Resume

useOnboardingGate reads onboardingStatus.progress.currentSlide from the Apollo cache (written by SYNC_USER_MUTATION) to support resume. shouldOnboard is true when:

  1. synced === true
  2. onboardingCompletedAt is falsy
  3. Not a fallback-auth or magic-link session

useOnboardingFlow maintains answers as reactive state. State is not reset between slide transitions. flow.reset() is called by DoneSlide after the mutation succeeds.


Accessibility & Motion

FeatureImplementation
Step counterDynamic (registry.length — currently 13)
Tab orderback → skip → content → footer CTA
Focus on mountHeading tabindex="-1" focus on each slide
Reduced motionConfetti skipped; CSS motion-reduce:transition-none motion-reduce:transform-none on interactive elements
Tap targetsAll buttons ≥44×44 (min-h-[44px] or w-11 h-11)
Coach persona gridrole="radiogroup" + role="radio" + aria-checked
Goal motivation groupsrole="group" per question with aria-label

Key Files

FilePurposeStatus
ionic_frontend/src/views/welcome/placeholderSlides.tsSlide registry (baseSlides + getPlaceholderSlides()) + OnboardingAnswers interfaceActive
ionic_frontend/src/views/welcome/slides/WelcomeIntro.vueSlide 1 — intro heroActive
ionic_frontend/src/views/welcome/slides/PhilosophySlide.vueSlide 1.5 — "Our Philosophy" (flag onboarding_pledge_enabled)Active
ionic_frontend/src/constants/ourStoryValues.tsShared OUR_STORY_VALUES + PHILOSOPHY_TAGLINE — single source for /our-story and the Philosophy slideActive
ionic_frontend/src/views/welcome/slides/LifeAreaSlide.vueSlide 2 — goal life area / categoryActive
ionic_frontend/src/views/welcome/slides/GoalNameSlide.vueSlide 3 — goal titleActive
ionic_frontend/src/views/welcome/slides/GoalShapeSlide.vueSlide 4 — shape + target dateActive
ionic_frontend/src/views/welcome/slides/GoalWhySlide.vueSlide 5 — motivationActive
ionic_frontend/src/views/welcome/slides/CoachPersonaSlide.vueSlide 6 — 6-persona selector (live write via UPDATE_COACHING_PREFERENCES_MUTATION)Active
ionic_frontend/src/views/welcome/slides/CoachToneSlide.vueSlide 7 — tone + depthActive
ionic_frontend/src/views/welcome/slides/CoachFocusSlide.vueSlide 8 — focus areasActive
ionic_frontend/src/views/welcome/slides/CoachRhythmSlide.vueSlide 9 — check-in frequencyActive
ionic_frontend/src/views/welcome/slides/GoalMotivationSnapshotSlide.vueSlide 10 — 4-question motivation profileActive
ionic_frontend/src/views/welcome/slides/SourceSlide.vueSlide 11 — attribution (skippable via URL params)Active
ionic_frontend/src/views/welcome/slides/ReviewSlide.vueSlide 12 — review + edit pencils (currently skip: () => true)Active (skipped)
ionic_frontend/src/views/welcome/slides/DoneSlide.vueSlide 13 — celebration + atomic goal creationActive
ionic_frontend/src/views/welcome/SlideWizardLayout.vueChrome wrapper, provide('routeName', ...)Active
ionic_frontend/src/composables/useOnboardingFlow.tsState machine for wizard answersActive
ionic_frontend/src/composables/useOnboardingGate.tsshouldOnboard + resumeSlideId computedActive
ionic_frontend/src/constants/graphql/user.jsCOMPLETE_ONBOARDING_AND_CREATE_GOAL_MUTATION, UPDATE_COACHING_PREFERENCES_MUTATION, UPDATE_GOAL_MOTIVATION_SNAPSHOT_MUTATIONActive
ionic_frontend/src/constants/coachPersonas.tsSix PersonaId definitions + COACH_PERSONAS dictionaryActive
rails_api/app/interactions/onboarding/complete_onboarding_and_create_goal.rbAtomic transaction: store onboarding state + create goalActive
rails_api/app/graphql/mutations/complete_onboarding_and_create_goal.rbGraphQL mutation resolverActive
ionic_frontend/src/views/welcome/slides/PersonaSlide.vueLegacy 4-option persona slide (Coach/Mentor/Ally/Captain)Orphaned — not in registry, retained alongside .stories.ts and .spec.ts
ionic_frontend/src/views/welcome/slides/GoalTypeSlide.vueLegacy goal category pickerOrphaned — not in registry, retained alongside .stories.ts and .spec.ts

Gamification

EventTriggerReward
Complete wizard with goalDoneSlide calls atomic mutation → GoalTracking::AddGoal succeedscreate_first_goal badge + confetti (2×cannon, 250ms offset, brand colors)
Complete wizard without goalDoneSlide routes to /goals/createNo immediate badge (deferred to goal creation)

Post-Wizard: Better Together Interstitial

After completing the wizard and landing on /dashboard?source=wizard, a BetterTogetherInterstitial.vue modal fires when the better-together-interstitial PostHog flag is enabled and the user has not previously dismissed it. This is a post-wizard surface — the 13-slide structure and sidebar-zero contract are not affected.

Trigger conditions (all must be true):

  1. route.query.source === 'wizard'
  2. PostHog flag better-together-interstitial is enabled
  3. User has not previously dismissed (module-level state in useBetterTogetherInterstitial)

Analytics events (onboardingAnalytics.ts): ally_invite_prompted, ally_invite_sent, ally_invite_skipped

Key files: ionic_frontend/src/components/dashboard/BetterTogetherInterstitial.vue, ionic_frontend/src/composables/useBetterTogetherInterstitial.ts


Metrics & KPIs

KPIDefinitionTarget
Onboarding completion% signups who reach DoneSlide and submit>80% (Day 1)
Time to first goalMinutes from signup to goal created via wizard❤️ min
Goal creation success% of DoneSlide submits (with goalName) that succeed>95%
Motivation snapshot completion% of users who answer ≥1 question on slide 10TBD

Test Coverage

  • Vitest unit: ionic_frontend/tests/unit/views/welcome/slides/ — per-slide render, events, validation
  • Vitest integration: ionic_frontend/tests/unit/views/welcome/welcomeFlow.integration.spec.ts
  • Vitest a11y: ionic_frontend/tests/unit/views/welcome/a11y.spec.ts
  • RSpec interaction: rails_api/spec/interactions/onboarding/complete_onboarding_and_create_goal_spec.rb
  • RSpec GraphQL: rails_api/spec/graphql/mutations/complete_onboarding_and_create_goal_spec.rb

Last updated: 2026-08-11

Loading…