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:
- Onboarding Timeline — Day 1–30 engagement arc, KPIs, gamification triggers
- Meet Coach — Coach configuration outside of onboarding
- Coach Surfaces — How coach preferences shape the in-app experience
Sidebar-zero Contract
All /welcome/* routes have meta.onboarding: true in the router. App.vue reads this flag to hide:
SideMenu—v-if="!route.meta.onboarding"BottomTabBar—v-if="!route.meta.public && !route.meta.onboarding"CoachDockCard— not rendered on onboarding routes (the Coach dock is a dashboard-only surface, not mounted inApp.vue)contentMargincomputed — returns0pxduring 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-area — only 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):
| ID | Name | Traits |
|---|---|---|
captain | The Captain | Structured · decisive |
spark | The Spark | Warm · contagious |
mirror | The Mirror | Reflective · non-directive |
rival | The Rival | Direct · competitive |
sage | The Sage | Steady · long-view |
analyst | The Analyst | Data-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:
| # | Question | Option 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:
| Field | Type | Values |
|---|---|---|
experience | String | null | never, a_few_times, many_still_going, many_struggled |
work_time | String | null | morning, afternoon, evening, varies |
challenge | String | null | starting, staying_consistent, momentum_after_setbacks, knowing_progress |
social_context | String | null | solo, friend_or_partner, team_or_group |
completed_at | String | null | ISO 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:
goalNameis present — callsCOMPLETE_ONBOARDING_AND_CREATE_GOAL_MUTATION(atomic), then routes to/dashboard?source=wizard.goalNameis absent — callsSTORE_ONBOARDING_STATE_MUTATIONonly, 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:
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):
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:
UserIdentity::StoreOnboardingState.call(...)— persistsonboarding_answersJSON blob to the user record, marks onboarding completeGoalTracking::AddGoal.call(...)— creates the goal withname,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:
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:
| Flag | Retired | Notes |
|---|---|---|
new-onboarding-v1-8 | v1.10 | Gate flag — wizard reached 100% rollout |
new-onboarding-v1-8-variant | v1.11 | Multivariate (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-enabled | Phase 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:
synced === trueonboardingCompletedAtis falsy- 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
| Feature | Implementation |
|---|---|
| Step counter | Dynamic (registry.length — currently 13) |
| Tab order | back → skip → content → footer CTA |
| Focus on mount | Heading tabindex="-1" focus on each slide |
| Reduced motion | Confetti skipped; CSS motion-reduce:transition-none motion-reduce:transform-none on interactive elements |
| Tap targets | All buttons ≥44×44 (min-h-[44px] or w-11 h-11) |
| Coach persona grid | role="radiogroup" + role="radio" + aria-checked |
| Goal motivation groups | role="group" per question with aria-label |
Key Files
| File | Purpose | Status |
|---|---|---|
ionic_frontend/src/views/welcome/placeholderSlides.ts | Slide registry (baseSlides + getPlaceholderSlides()) + OnboardingAnswers interface | Active |
ionic_frontend/src/views/welcome/slides/WelcomeIntro.vue | Slide 1 — intro hero | Active |
ionic_frontend/src/views/welcome/slides/PhilosophySlide.vue | Slide 1.5 — "Our Philosophy" (flag onboarding_pledge_enabled) | Active |
ionic_frontend/src/constants/ourStoryValues.ts | Shared OUR_STORY_VALUES + PHILOSOPHY_TAGLINE — single source for /our-story and the Philosophy slide | Active |
ionic_frontend/src/views/welcome/slides/LifeAreaSlide.vue | Slide 2 — goal life area / category | Active |
ionic_frontend/src/views/welcome/slides/GoalNameSlide.vue | Slide 3 — goal title | Active |
ionic_frontend/src/views/welcome/slides/GoalShapeSlide.vue | Slide 4 — shape + target date | Active |
ionic_frontend/src/views/welcome/slides/GoalWhySlide.vue | Slide 5 — motivation | Active |
ionic_frontend/src/views/welcome/slides/CoachPersonaSlide.vue | Slide 6 — 6-persona selector (live write via UPDATE_COACHING_PREFERENCES_MUTATION) | Active |
ionic_frontend/src/views/welcome/slides/CoachToneSlide.vue | Slide 7 — tone + depth | Active |
ionic_frontend/src/views/welcome/slides/CoachFocusSlide.vue | Slide 8 — focus areas | Active |
ionic_frontend/src/views/welcome/slides/CoachRhythmSlide.vue | Slide 9 — check-in frequency | Active |
ionic_frontend/src/views/welcome/slides/GoalMotivationSnapshotSlide.vue | Slide 10 — 4-question motivation profile | Active |
ionic_frontend/src/views/welcome/slides/SourceSlide.vue | Slide 11 — attribution (skippable via URL params) | Active |
ionic_frontend/src/views/welcome/slides/ReviewSlide.vue | Slide 12 — review + edit pencils (currently skip: () => true) | Active (skipped) |
ionic_frontend/src/views/welcome/slides/DoneSlide.vue | Slide 13 — celebration + atomic goal creation | Active |
ionic_frontend/src/views/welcome/SlideWizardLayout.vue | Chrome wrapper, provide('routeName', ...) | Active |
ionic_frontend/src/composables/useOnboardingFlow.ts | State machine for wizard answers | Active |
ionic_frontend/src/composables/useOnboardingGate.ts | shouldOnboard + resumeSlideId computed | Active |
ionic_frontend/src/constants/graphql/user.js | COMPLETE_ONBOARDING_AND_CREATE_GOAL_MUTATION, UPDATE_COACHING_PREFERENCES_MUTATION, UPDATE_GOAL_MOTIVATION_SNAPSHOT_MUTATION | Active |
ionic_frontend/src/constants/coachPersonas.ts | Six PersonaId definitions + COACH_PERSONAS dictionary | Active |
rails_api/app/interactions/onboarding/complete_onboarding_and_create_goal.rb | Atomic transaction: store onboarding state + create goal | Active |
rails_api/app/graphql/mutations/complete_onboarding_and_create_goal.rb | GraphQL mutation resolver | Active |
ionic_frontend/src/views/welcome/slides/PersonaSlide.vue | Legacy 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.vue | Legacy goal category picker | Orphaned — not in registry, retained alongside .stories.ts and .spec.ts |
Gamification
| Event | Trigger | Reward |
|---|---|---|
| Complete wizard with goal | DoneSlide calls atomic mutation → GoalTracking::AddGoal succeeds | create_first_goal badge + confetti (2×cannon, 250ms offset, brand colors) |
| Complete wizard without goal | DoneSlide routes to /goals/create | No 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):
route.query.source === 'wizard'- PostHog flag
better-together-interstitialis enabled - 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
| KPI | Definition | Target |
|---|---|---|
| Onboarding completion | % signups who reach DoneSlide and submit | >80% (Day 1) |
| Time to first goal | Minutes 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 10 | TBD |
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