Scout — Full Product Context → feature documentation

Onboarding

Scout's first-run flow: a four-step tutorial over one continuous map whose last step is the location/notification permission ask, then a dedicated first-unlock reveal, then home.

Summary

Scout's first-run flow, run once per device before a new user reaches the app: a four-step tutorial over one continuous map (/onboarding-tutorial), whose fourth step is the Location and Notifications permission ask itself, and a dedicated first-unlock reveal (/onboarding-reveal) — always, as the final step — and then home. The flow is tutorial → reveal → home: the app does not ask "How did you hear about us?" any more (that step is switched off — see Status). There are no standalone permission screens: /onboarding-location and /onboarding-notifications were deleted on 2026-09-20 and the ask moved inside the tutorial as step 4, which is what the approved design (mobile/research/onboarding-tutorial-lab.html, variant C "Live Signal") always specified. It shipped 2026-09-20 to fix two findings from a user-research panel: nobody could tell "outside the boundary" from "broken" (comprehension of the core loop itself was never the problem — 23 of 23 participants stated it correctly unprompted), and the free city/state patch that used to fire during setup pre-spent the emotional payoff of a user's first real arrival. The tutorial's job is narrow: teach when a patch fires, and make the first unlock land as the lesson rather than as a freebie the user didn't earn.

A single GPS fix can claim several nested patches at once (state → city → park → landmark). On the ordinary celebration screen that is N full-screen modals, one per patch (product decision, 2026-09-21, reversing a one-day "N at once" card) — but the first-run reveal shows the whole pass on ONE screen, and it is the only place that happens. The same change collapses more than two simultaneous achievement unlocks into one summary card instead of playing each individually. Both are shared infrastructure Onboarding depends on but does not own outright — see unlock-and-location and achievements for the full mechanism; this doc covers the onboarding-specific screens and routing.

Status

Shipped, unconditionally on for every first-run device. No feature flag gates any of it — tutorialSeen (the flag that marks the flow complete) is a plain persisted boolean in the mobile Zustand store (mobile/src/domain/store.ts:866, whitelisted for persistence at partialize.test.ts:36), not a server-controlled rollout switch. Because it is device-local, the tutorial replays after every reinstall — this is the confirmed, intended behavior (Alan, 2026-09-20), not a bug to fix later. The reveal step has its own persisted flag, firstRunRevealSeen, for the same reason and with the same lifetime (see Data model).

User-facing surfaces

How it works

  1. Routing. resolveOnboardingRoute (mobile/src/components/permissions/permissionRouting.ts:76) takes a single options object — { tutorialSeen, revealSeen, referralAsked, isRealAccount } — and returns a route in a fixed priority order: !tutorialSeen → /onboarding-tutorial; !revealSeen → /onboarding-reveal; referralCaptureOwed({ hasRealAccount: isRealAccount, referralAsked }) → /onboarding-referral; else /(drawer). That third branch is unreachable todayreferralCaptureOwed returns false while the capture step is switched off — so the live resolver has three outcomes, not four. There are no location / notifications inputs: the only screen that asks is the tutorial itself, so an OS permission status cannot change where onboarding goes next. It moved from five positional arguments to an options object specifically so this change could insert tutorialSeen and the reveal branch in the middle without silently breaking existing call sites. The reveal's input is revealSeen (the persisted firstRunRevealSeen), not "is anything queued": the resolver cannot see the unlock queue and must not, or the empty state is unreachable by construction. Each screen in the chain calls it again with its own step marked handled (onboarding-tutorial.tsx passes tutorialSeen: true, onboarding-reveal.tsx passes revealSeen: true) to ask "what's next," so the resolver is the single source of truth for the whole sequence, not each screen guessing its successor.
  2. tutorialSeen is set at the very end of the flow, not when the tutorial screen itself is dismissed. onboarding-tutorial.tsx's own finish() passes tutorialSeen: true to the resolver to compute the next route but never calls useAppStore.getState().setTutorialSeen; only onboarding-reveal.tsx's handleContinue — which is now every first-run device's route to the end — actually flips it, with the terminal /(drawer) hop inside goToOnboardingRoute as the backstop for any path that reaches home another way. This matters because canCelebrateOnScreen (mobile/src/utils/celebrationGate.ts:41) gates all celebrations and the achievement deck on tutorialSeen — flipping it early would let a stray celebration pop over the middle of the tutorial's permission step.
  3. Celebrations are suppressed for the whole flow. Every onboarding-* root is in CELEBRATION_BLOCKED_ROOTS (mobile/src/utils/celebrationGate.ts) — auth, onboarding-referral, onboarding-tutorial, onboarding-reveal, plus location-permission and celebration itself. A patch unlocked automatically during onboarding (background/foreground location tracking runs the whole time, permission-status permitting) is simply left queued in the store's unlockedQueue, never copied anywhere else — see unlock-and-location for the queue mechanism. A drift test (mobile/src/utils/__tests__/celebrationGate.test.ts, "blocks celebrations on every registered onboarding route") walks every onboarding-* entry in deepLinkRoutes.ts and fails the build if it is missing from CELEBRATION_BLOCKED_ROOTS — this is a real regression class: omitting onboarding-referral once shipped a bug where an inherited patch's celebration pushed straight over the referral screen a second after it rendered.
  4. /onboarding-reveal consumes the batch, not just reads it. Its handleContinue calls dequeueUnlockedBatch() and dequeueAllAchievements() before setFirstRunRevealSeen(true), setTutorialSeen(true) and navigating on. If it only read the queue, tutorialSeen would flip true, the celebration watcher would un-gate immediately, and the same batch would pop a second time as a full-screen /celebration modal the instant the user lands home — the exact pile-on this screen exists to prevent. A screen test (mobile/screen-tests/onboarding-reveal.test.tsx, "empties the queue so nothing replays at home") asserts the store's unlockedQueue is empty after Continue.
  5. Achievements earned during onboarding get no separate slide. The same okToCelebrate/canCelebrateOnScreen gate that blocks patch celebrations also blocks AchievementDeck, so six to eight family: 'app' achievements (opened Compass, opened Map, etc.) can be sitting in achievementQueue by the time the reveal renders. PatchUnlockReveal shows one subordinate row instead — "Also unlocked · N achievements" (testID patch-unlock-reveal-also) — in tertiary grey below a hairline, tapping through to /achievements (wired on this screen via onOpenAchievements). Because this screen is the only thing that ever shows that batch, it also owns the drain: Continue calls dequeueAllAchievements() alongside dequeueUnlockedBatch(), so the deck cannot re-announce the same achievements as a summary card the moment the user lands home. Announcing them twice is the exact pile-on this change exists to remove (spec Decision 3: "the onboarding batch is then dequeued with the patches, so nothing replays at home"). Achievements earned AFTER onboarding are unaffected — AchievementDeck remains their only consumer.
  6. The marquee sky renders before any location fix exists. Screen 1 of the tutorial runs before the permission ask, so on a genuine first run there is no GPS fix to be "near." resolveMarqueePatches (MARQUEE_PATCHES.ts) resolves a fixed six-id lineup from the already-synced catalogue (useContent().patches) rather than any location query — see Data model.
  7. The crossing is driven by plain JS timers, never by requestAnimationFrame or a Reanimated UI-thread callback. TutorialMap.tsx's step-2 walk runs on setInterval/setTimeout, with an independent guard timer that fires the crossing unconditionally if the tick loop itself never ran for any reason (a backgrounded/ suspended view, since rAF-driven loops throttle or stop when the view isn't foregrounded). A screen test overrides useReducedMotion to false and stubs out setInterval entirely, then asserts the crossing still fires from the guard timer alone (onboarding-tutorial.test.tsx, "fires the crossing from the guaranteed fallback even when the tick loop never runs").

Data model

API surface

Onboarding introduces no new backend endpoints. It reads the already-synced patch catalogue (GET /api/sync/content, see sync-and-catalog-delivery) and otherwise routes entirely client-side.

Key files

Configuration and flags

No server feature flag gates any part of onboarding. tutorialSeen and referralAsked are plain persisted client booleans, not rollout switches — see Status.

One build-time constant does gate a step: REFERRAL_CAPTURE_IN_ONBOARDING (mobile/src/components/referral/referralCapture.ts), currently false. It is a source constant, not a remote flag — changing it needs a build. It is read by referralCaptureOwed, which is the single predicate behind both independent paths to the "How did you hear about us?" screen:

  1. the first-run resolver, resolveOnboardingRoute (mobile/src/components/permissions/permissionRouting.ts); and
  2. the standing condition — AuthGate's referralOwed (mobile/app/_layout.tsx) and OnboardingProvider's post-onboarding branch (mobile/src/providers/OnboardingProvider.tsx) — which is not part of the first-run chain at all: it re-routes any signed-in real account that was never asked, on a device that finished onboarding long ago.

Closing only (1) would have left the screen appearing for exactly the users it was built for, which is why they share one switch. Because nothing sets referralAsked any more, every account is permanently in the "never asked" state — that is the state both paths used to fire on, and the tests pin that neither does now.

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Open questions