Scout — Full Product Context → feature documentation

Onboarding

Scout's first-run flow: a five-step tutorial (one real patch shown as a preview, the boundary lesson, a real collection page, the account step where a new device signs in or signs up, then the location/notification permission ask), then a dedicated first-unlock reveal, then an offer to scan the camera roll, then home. Every signed-out device lands on the tutorial's first step; one that has finished it before goes straight in once it signs in on the account step.

Summary

Scout's first-run flow, and on a fresh device the FRONT DOOR of the app (changed 2026-10-24): a signed-out device opens straight on the tutorial, whose account step is where it signs in or signs up (the old standalone sign-in-first screen is no longer the entry point; see auth-account-and-profile). The tutorial is a five-step tutorial (/onboarding-tutorial: intro, boundary, collection, account, permissions), whose last step is the Location and Notifications permission ask itself, and a dedicated first-unlock reveal (/onboarding-reveal), then one more beat on the same route, the camera-roll beat ("Fill in where you've already been"), which offers the import and then goes home. Every account reaches both, always. The flow is tutorial → reveal → camera-roll beat → 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 its last step.

The seven beats, in order (the tutorial's five are STARTER_STEPS, mobile/src/components/onboarding/starterPack.ts:25-31):

  1. One real patch — a real catalogue patch (Boston Common when the catalogue has it) rising over a photo of its own place, with its name and rarity. A look at the catalogue, not a gift.
  2. The boundary — a walker crosses an illustrated, invented park fence; nothing fires until the line is crossed.
  3. The collection page — a real collection laid out as the page you will fill, at the user's real count (0 for a new user), with the next patch to chase lit.
  4. The account step (added 2026-10-24, redesigned 2026-10-25; a quiet "Why do I need an account?" link opens the /why-account sheet on a tap, never by itself) — the Field Museum patch, "Keep every patch you earn.", one sentence on what a free account keeps (patches, badges, posts, comments, on any phone), then Apple, Google, email or a password. A sign-in here advances in place to the permission ask. No Skip, no footer.
  5. The permission ask — location, then notifications, with the disclosure on screen before either OS dialog.
  6. The reveal (/onboarding-reveal) — the first real unlock, or the honest empty state.
  7. The camera-roll beat (same route, every account) — "Scan my camera roll" or "Maybe later". It never asks for photo permission itself; the import screen does.

Account step added 2026-10-24 (in store builds since v1.1.112). Before this, the sign-in screen came first and the tutorial followed a sign-in. Now the lesson comes first and the sign-in is its fourth step, so the user has seen what Scout is before being asked for an account. (The design was written up as the "account after the tutorial" spec.) The account step's panel is shared with /auth (see Surfaces).

Streamlined 2026-10-19 (Alan, picked from the lab; in store builds since v1.1.110): the tutorial was cut from five steps to four, replacing the sealed "Field Pack" and the four rarity cards with the one-patch intro, and the camera-roll beat was added after the reveal. Design source: mobile/research/onboarding-gamified-lab.html, "Round 3 · Streamlined" (R3), with the camera-roll beat from "R2 · B · Do it, after the reveal". Why: Alan wanted the flow shorter to the lesson, and users do not know camera-roll import exists. What was given up is the up-front rarity ladder lesson; rarity still shows on the intro patch and on every patch elsewhere in the app. The five-step Starter Pack (variant B of the same lab, 2026-10-02) had itself replaced the four-step "one continuous map" tutorial (mobile/research/onboarding-tutorial-lab.html, variant C, shipped 2026-09-20).

The tutorial exists because of 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 boundary step is still the lesson; the intro patch and the collection page are there to show what there is to collect before the app asks for location. The tutorial awards nothing — the first real patch comes from the first real arrival.

A single GPS fix can claim several nested patches at once (state → city → park → landmark). The first-run reveal shows the whole pass on ONE screen — the innermost patch surging, the rest listed under it as "Also unlocked" — and since 2026-10-02 the ordinary celebration at home does the same: one celebration per arrival, not one per patch (see unlock-and-location). 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: the tutorial, reveal and camera-roll beat, and since v1.1.112 the account-step front door. No feature flag gates the flow itself (the one flag in play, guest_mode, only hides the account step's guest link and ships off) — tutorialSeen (the flag that marks the flow complete) is a plain persisted boolean in the mobile Zustand store (mobile/src/domain/store.ts:886, whitelisted for persistence at partialize.test.ts:36), not a server-controlled rollout switch. The reveal step has its own persisted flag, firstRunRevealSeen, with the same lifetime (see Data model).

When it shows (changed 2026-10-24, Alan; before that 2026-10-02): the rule is a fact about the PHONE, not the account.

Before 2026-10-24 the sign-in screen came first and the tutorial followed it; the provider waited on hasChosenAuthPath to avoid a bounce back to /auth, and every sign-in (even on a phone that had done the tutorial) replayed the whole flow. None of that is true any more. Tests: mobile/src/providers/__tests__/authRedirect.test.ts, mobile/src/providers/__tests__/OnboardingProvider.test.tsx, mobile/src/providers/__tests__/AuthProvider.test.tsx, mobile/src/domain/__tests__/store.deviceOnboarded.test.ts, mobile/screen-tests/root-layout-auth-gate.test.tsx, mobile/screen-tests/onboarding-reveal.test.tsx ("clears the owed tutorial on Start exploring", which keeps its old name; the button now reads "Next").

Which build shows which tutorial:

The permission ask, the reveal's states and the routing are the same in all three.

User-facing surfaces

How it works

  1. Routing. resolveOnboardingRoute (mobile/src/components/permissions/permissionRouting.ts:92) 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 today — referralCaptureOwed 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:44) 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) and setTutorialSeen(true). Since 2026-10-19 it then switches to the camera-roll beat instead of navigating, and the beat's two buttons do the navigating. 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. The reveal shows one subordinate row instead — "Also earned · N achievements" on the Ember Surge reveal, "Also unlocked · N achievements" on the empty state (testID patch-unlock-reveal-also on both) — 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 intro patch is hardcoded; the collection page and the camera-roll examples come from the synced catalogue, before any location fix exists. Steps 1-3 run before the permission ask, so on a genuine first run there is no GPS fix to be "near." The intro patch is INTRO_PATCH (the catalogue is not read for it: a new install opens the tutorial before /api/sync/content has answered, ~10 s on prod on 2026-11-04). The collection page (resolveCollectionPage(collections, patchCollections, patches, collectedIds)) are both computed from the synced catalogue (useContent()) and the user's real collected ids (useCollectedPatchIds()), with no location query and no network call of their own (onboarding-tutorial.tsx:164-168). The camera-roll beat's examples (resolveRetraceExamples(patches), onboarding-reveal.tsx:131) come from the same catalogue. Both resolvers are pure functions in starterPack.ts — see Data model.
  7. Every step change is driven by a plain JS timer or a press, never by an animation finishing. Since 2026-10-19 the only timer that moves the flow on by itself is the permission auto-advance (setTimeout, onboarding-tutorial.tsx:318-322); the intro's burst (:210-218) and the collection slot's beat (:232-237) are timers that only decorate. (The pack's hand-off to the cards and Legendary's held breath were timers too, and went with them.) The boundary crossing is the one that matters most: it fires on a setTimeout at crossingAtMs(reduceMotion) (onboarding-tutorial.tsx:220-230) — WALK_DELAY_MS + crossingFraction() × WALK_MS with motion on, about 2.55 s, or 260 ms under reduced motion (BoundaryStep.tsx:42-44). crossingFraction() bisects the walk for the first point inside the fence (starterPack.ts:240-249), and the walker moves at a linear pace over the same delay and duration (BoundaryStep.tsx:56-60), so the timer and the picture agree. An animation can be skipped (reduced motion), stalled (a backgrounded app) or dropped; the "now you're inside, now it fires" beat lands anyway. The feet readout runs on its own 100 ms setInterval clock inside WalkReadout, so the countdown re-renders only itself (BoundaryStep.tsx:163-176).
  8. Effects are fired into one shared ember field and never gate anything. The route owns a single useEmberField() controller (onboarding-tutorial.tsx:136) and the helpers in starter-pack/fx.ts — AMBIENT (the resting level), centerOf and popAt (ring + burst, plus a whole-field recolour for Rare and up) — measure the target element with measureInWindow and draw into it. popAt returns immediately under reduced motion. The step machine never reads anything back from the field. (legendaryAt and settle were deleted on 2026-10-19 with the rarity cards.)
  9. The camera-roll beat comes after onboarding is already done (since 2026-10-19). The reveal's Next press runs the whole exit — analytics, drain, flags — before the beat shows, so the beat is only a choice of where to go. Consequences, each deliberate (onboarding-reveal.tsx header comment, :34-46):
    • A kill on the camera-roll beat lands home on next launch. firstRunRevealSeen, tutorialSeen and onboardingPending are already settled, so neither the reveal nor the beat replays.
    • The returning-account skip applies only in the reveal phase (skipAsReturning, onboarding-reveal.tsx:244). After Next drains the queue, a brand-new account that just collected its first patch owns a patch and has no batch — exactly what "returning" looks like — and must not be bounced home from the beat. A returning account that is skipped past the reveal lands on the camera-roll beat like everyone else (handleContinue('already_collected') ends in the same setPhase('retrace')).
    • No rating ask. The reveal asked for a store review (on its own button, then on "Maybe later") until 2026-10-27. Its patches arrive the moment the app is installed, so the ask was spent before the person had done anything. It now waits for the first live unlock's celebration (unlock-and-location.md).

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

Analytics events

Since 2026-09-30 (tracking plan Change 2). Full table in analytics-events.md. Signatures: mobile/src/services/analytics.ts:512-565.

Tests that cover it

Open questions