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
/onboarding-tutorial(mobile/app/onboarding-tutorial.tsx) — the ONLY screen of onboarding before the reveal, and the only place the app ever asks for a permission. One continuous map underlay (TutorialMap.tsx) with four internal steps, a shared footer CTA (tutorial-next), and aSkipcontrol (testIDtutorial-skip) that jumps to step 4 — it skips the lesson, never the permission ask, and is hidden on step 4 itself:-
What Scout is — no card; a drifting "marquee sky" of six real National Park patches at three parallax depths (see Data model below), largest/hero patch arriving last so the screen assembles around it.
-
The boundary — a walker crosses an irregular polygon on its own (no drag, no user input) while a live "distance" readout counts down. The crossing itself is three beats, in order (the reason variant C was chosen over the other two): the perimeter ignites, a lit stroke running round the polygon like a fuse (~1.05s); then the interior fills while a warm bloom flashes over the scene (~1.25s); then the patch lifts off the map with a shadow opening beneath it (~1.05s). The readout flips from "Outside — nothing fires." to "Inside — the patch unlocked.", and the CTA (disabled until the crossing happens) un-gates. Beats 2 and 3 are scheduled off a plain
setTimeouthand-off, never off beat 1's animation completing. The lifting patch is illustrative art (the hero marquee patch), not an unlock — nothing is awarded here. -
Collections — the map recedes to atmosphere and the top of the frame shows the real City Challenge screen's hero (
TutorialChallengeCard, testIDtutorial-challenge-card): the sameHeroandProgressRingcomponents the shipped City Challenge composes, not a drawing of them, carrying the collection's own photo, its name, a progress ring, ann STOPS / n COLLECTED / n TO GOstat row and aNext: <stop>label. It is inert (pointerEvents="none") and is one accessibility element with a summarising label. Below it a row of four patch thumbnails (the lab's.pinrow, testIDtutorial-collection-pinrow) lights in sequence. One counter drives the card's ring, its stats and the thumbnails together, so they can never disagree. The four thumbnails are real member patches of the collection, resolved out of the synced catalogue's membership links; with a cold catalogue the row holds its four slots empty rather than inventing art. The collection is likewise a real row from the synced catalogue, shown partly filled (TUTORIAL_COLLECTION.ts— the lab's Boston set at 4 of 11, or the nearest real equivalent): a made-up name, or a ring already at 100%, would teach the opposite of the lesson. The card's photo and itsNext:label come from a member still TO GO, by the same rule the shipped screen uses (selectPlaceHeroPatch).This replaced an abstract graphic — the previous beat's boundary polygon plus five scattered dots joined by dashed threads — which said nothing about collections and was reported as "the unlock grid at the top is really weird". One five-dot strip spans the whole flow (
mobile/src/components/onboarding/TutorialBeatDots.tsx), not one set per screen: beats 1-4 are the tutorial's steps and beat 5 is/onboarding-reveal, which renders the same strip on index 4. It shipped as four dots on the tutorial and none on the reveal, so the flow claimed to be over one beat early and then produced another screen anyway. testIDs aretutorial-step-dot-<i>, suffixed-onfor the active beat and-donefor the ones behind it. -
The permission ask (
onboarding-tutorial.tsx, step index 3; testIDtutorial-permission-rows) — no illustration at all: the map draws only its grid and warm wash here, with the boundary polygon, the contour rings and the scenery pins all switched off, and the copy block is vertically centred against a pinned CTA. A permission ask has to get a disclosure read and two questions answered, and the decorative polygon that used to sit at the top competed with both. KickerREADY, headline "Now let's earn one for real.", and two rows, Location and Notifications, each a ring-with-tick that fills and springs a tick into place when the OS answers yes, a name and a one-line subtitle (PermissionScreenConfig.rowSubtitle— "So a patch fires the moment you arrive" / "A quiet signal when one unlocks"). The full background-location disclosure is BODY COPY above the rows, not inside one; it was briefly crammed into the Location row beside its 30pt ring, which buried the one sentence that has to be read and made the step look unfinished. One button, pressed twice: press one opens the real OS location dialog (useLocation().requestPermissions()thengetPermissionStatus()), press two the real notification dialog (requestNotificationPermission()); once both are answered the screen advances to/onboarding-revealon its own after ~700ms, with no third press — the lab'spermissionDriver()sequencing.markPermissionsPrompted()fires on entering the step, so the home-screen auto-prompt (src/hooks/usePermissionPrompt.ts) never re-asks. The answered row's ring fills (testIDstutorial-perm-location-done/-pending). Three App Review 5.1.1(iv) obligations live on this step: (a) the background-location disclosure —LOCATION_SCREEN.default.body, reused verbatim frommobile/src/components/permissions/PERMISSION_SCREENS.ts, including the sentence "Scout uses your location — including in the background, even when the app is closed" — is on screen from the first frame as body copy (testIDtutorial-perm-location-disclosure), before anything can open the dialog. The lab has no such paragraph on this screen; it is added, not substituted, and the rows' one-liners never replace it; (b) the CTA label is neutral —LOCATION_SCREEN.ctaLabel/NOTIFICATIONS_SCREEN.ctaLabel, both "Continue", never the lab's "Turn on location", which would pre-commit the user's answer on Apple's behalf; (c) declining location still leaves a real alternative — the Location row swaps toLOCATION_SCREEN.denied.body, which points at camera-roll import (/import-modal). There is no in-app skip past this step.
- Deep link
scout://onboarding-tutorial, registered inmobile/src/dev/deepLinkRoutes.ts.
-
/onboarding-reveal(mobile/app/onboarding-reveal.tsx) — the tutorial's last screen, and an unconditional step of first-run: every new device reaches it exactly once, whether or not anything unlocked while permissions were being granted. Renders the sharedPatchUnlockRevealcomponent (see unlock-and-location) inline over theEmbersBackgroundparticle field — the same Skia ember atmosphere the real/celebrationscreen and the trip finale use, in place of the houseAnimatedGridBackground(showGrid={false}), so the two moments that reward you share an atmosphere and nothing drifts behind the art. The patch art is sized from the frame and the count (fanTileSize— three across at ~92pt on a 390pt frame, larger for one or two, capped so a single patch cannot fill a tablet) rather than a flat 72pt thumbnail. Three states: ≥1 patch unlocked (named, with the nesting sentence — "You crossed into Boston, which sits inside Massachusetts"), a brief waiting state using the housePageLoader/Skeletonprimitives (never a bare spinner, per CLAUDE.md), and an explicit empty state ("You're not inside anything — yet.") that is never a dead end — it still offers Continue. The empty state is the point of the step running unconditionally: it was previously unreachable, because the router only sent users here when there was already something to show, and the sparse user — two of the research panel's five had nothing within 25 miles, one of them in a state capital — finished onboarding with no closure at all. All three states announce themselves viaannounceForAccessibility, the empty one included. This is the only place the first unlock is shown: the normal full-screen/celebrationmodal is suppressed for the entire onboarding flow (see How it works). No purchase affordance renders here — a first-run teaching moment does not carry a checkout. Deep linkscout://onboarding-reveal(mobile/src/dev/deepLinkRoutes.ts:132)./onboarding-referral— DORMANT since 2026-09-21. No user reaches this screen. The "How did you hear about us?" capture step is switched off atmobile/src/components/referral/referralCapture.ts(REFERRAL_CAPTURE_IN_ONBOARDING = false), which both routing paths read viareferralCaptureOwed. The screen itself, its screen test and its deep link (scout://onboarding-referral) are all deliberately kept so it stays reachable for testing and can be switched back on in one edit; a test (mobile/src/components/referral/__tests__/referralCapture.test.ts) fails the build if any of them is deleted. Onboarding no longer ends here — the reveal is the last step.- Achievement summary card —
mobile/src/components/achievements/AchievementDeck.tsx. Not onboarding-specific, but load-bearing for a new account: a first session can earn six to eightfamily: 'app'achievements just from opening screens, and playing each as its own ~3.2s card would be a 20+ second backlog. Above two queued achievements, the deck collapses to a singleSummaryCard(testIDachievement-card-summary) naming the count and up to three achievement names, held longer, dequeuing the whole queue at once. Two or fewer still play as individual cards, unchanged.
How it works
- 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 today —referralCaptureOwedreturns false while the capture step is switched off — so the live resolver has three outcomes, not four. There are nolocation/notificationsinputs: 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 inserttutorialSeenand the reveal branch in the middle without silently breaking existing call sites. The reveal's input isrevealSeen(the persistedfirstRunRevealSeen), 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.tsxpassestutorialSeen: true,onboarding-reveal.tsxpassesrevealSeen: 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. tutorialSeenis set at the very end of the flow, not when the tutorial screen itself is dismissed.onboarding-tutorial.tsx's ownfinish()passestutorialSeen: trueto the resolver to compute the next route but never callsuseAppStore.getState().setTutorialSeen; onlyonboarding-reveal.tsx'shandleContinue— which is now every first-run device's route to the end — actually flips it, with the terminal/(drawer)hop insidegoToOnboardingRouteas the backstop for any path that reaches home another way. This matters becausecanCelebrateOnScreen(mobile/src/utils/celebrationGate.ts:41) gates all celebrations and the achievement deck ontutorialSeen— flipping it early would let a stray celebration pop over the middle of the tutorial's permission step.- Celebrations are suppressed for the whole flow. Every
onboarding-*root is inCELEBRATION_BLOCKED_ROOTS(mobile/src/utils/celebrationGate.ts) —auth,onboarding-referral,onboarding-tutorial,onboarding-reveal, pluslocation-permissionandcelebrationitself. A patch unlocked automatically during onboarding (background/foreground location tracking runs the whole time, permission-status permitting) is simply left queued in the store'sunlockedQueue, 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 everyonboarding-*entry indeepLinkRoutes.tsand fails the build if it is missing fromCELEBRATION_BLOCKED_ROOTS— this is a real regression class: omittingonboarding-referralonce shipped a bug where an inherited patch's celebration pushed straight over the referral screen a second after it rendered. /onboarding-revealconsumes the batch, not just reads it. ItshandleContinuecallsdequeueUnlockedBatch()anddequeueAllAchievements()beforesetFirstRunRevealSeen(true),setTutorialSeen(true)and navigating on. If it only read the queue,tutorialSeenwould flip true, the celebration watcher would un-gate immediately, and the same batch would pop a second time as a full-screen/celebrationmodal 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'sunlockedQueueis empty after Continue.- Achievements earned during onboarding get no separate slide. The same
okToCelebrate/canCelebrateOnScreengate that blocks patch celebrations also blocksAchievementDeck, so six to eightfamily: 'app'achievements (opened Compass, opened Map, etc.) can be sitting inachievementQueueby the time the reveal renders.PatchUnlockRevealshows one subordinate row instead — "Also unlocked · N achievements" (testIDpatch-unlock-reveal-also) — in tertiary grey below a hairline, tapping through to/achievements(wired on this screen viaonOpenAchievements). Because this screen is the only thing that ever shows that batch, it also owns the drain: Continue callsdequeueAllAchievements()alongsidedequeueUnlockedBatch(), 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 —AchievementDeckremains their only consumer. - 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. - The crossing is driven by plain JS timers, never by
requestAnimationFrameor a Reanimated UI-thread callback.TutorialMap.tsx's step-2 walk runs onsetInterval/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 overridesuseReducedMotiontofalseand stubs outsetIntervalentirely, 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
tutorialSeen: boolean(mobile/src/domain/store.ts:866) — the persisted flag drivingresolveOnboardingRoute's first branch andcanCelebrateOnScreen's gate. Client-only; there is no server-side mirror of "has this account seen the tutorial," so a fresh reinstall on the same account replays it.firstRunRevealSeen: boolean(mobile/src/domain/store.ts) — the persisted flag that makes/onboarding-revealrun exactly once per device. Set by that screen's own Continue, alongsidetutorialSeen. Separate fromtutorialSeenbecause the two answer different questions at the same moment:tutorialSeenmeans "onboarding is over" and is what un-gates celebrations, so it cannot be set before the reveal without letting one pop over the flow. The store's persistmergebackfills it totruefor any blob that already hastutorialSeen— without that, shipping this change would hand a first-run step to every already-onboarded install on earth.unlockedQueue: QueuedUnlock[](store.ts:497,584) —{ patch, passId }[], the shared queue every automatic unlock (onboarding or not) writes into. Onboarding's only interaction with it is reading the head batch (selectHeadBatch) for the reveal and draining the WHOLE PASS (dequeueUnlockedBatch) on Continue — it introduces no onboarding-specific queue of its own. The ordinary celebration drains the same queue ONE PATCH at a time (dequeueUnlockedPatch); both act on the single copy, and the reveal's drain must remove everything it displayed or the remainder resurfaces at home as individual modals. Full shape and batching rules documented in unlock-and-location.resolveTutorialCollection(mobile/src/components/onboarding/TUTORIAL_COLLECTION.ts) — step 3's collection: a real catalogue row (the lab'sbostonwhen present, else the first collection of 6-20 patches by name, else the largest), its realpatchCount, an illustratedcollectedclamped to1 <= collected <= TUTORIAL_PINROW_COUNTand always< totalso the ring is never complete,members— up to four of its real member patches, by name, for the thumbnail row — and the Challenge card's ownheroImageUrl/heroBlurhash/nextName, taken from a member still to go (falling back to any photographed member) and never invented.nullwhen nothing is synced, which the screen renders as a placeholder rather than a fabricated name.MARQUEE_PATCH_IDS(mobile/src/components/onboarding/MARQUEE_PATCHES.ts) — a fixed array of six real catalogue patch ids, Yellowstone first (the hero):np-yellowstone-national-park,np-yosemite-national-park,np-grand-canyon-national-park,np-zion-national-park,np-great-smoky-mountains-national-park,np-acadia-national-park. These ship in the synced catalogue, so the sky renders with no network call of its own (content sync already ran or is cached before onboarding reaches this screen).resolveMarqueePatches(patches)looks each id up in the synced catalogue and returns that exact lineup only if every id resolves; if even one is missing (a catalogue change, an unseeded dev environment, a stale cache mid-migration) it falls back to the first six catalogue patches rather than a partial or empty sky, because screen 1 has nothing else to show before the location ask.
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
mobile/app/onboarding-tutorial.tsx— the four-step screen, its step state machine, the marquee sky (MarqueeSky/SkyPatch), step 4'sPermissionRowand itsPermStagedriver (driveLocation/driveNotifications/ the auto-advance effect), andfinish().mobile/src/components/onboarding/TutorialMap.tsx— the one continuous SVG map under all four steps: the boundary polygon (POLY), the ray-cast containment test (inPoly, the same algorithm the runtime unlock check uses against a real geofence, ported for a fake shape), the walk path and its plain-timer crossing driver, and the per-step camera (CAMERA_BY_STEP) with a 55%-speed parallax far layer.mobile/src/components/onboarding/MARQUEE_PATCHES.ts—MARQUEE_PATCH_IDS,resolveMarqueePatches.mobile/src/components/onboarding/tutorialGeometry.ts— the lab's published geometry, resolved in POINTS:SKY_LAYOUT+skyBox(screen 1's six patches, centred on their coordinate the way.cp'stranslate(-50%,-50%)does),liftoffBox(the crossing's payoff), and the camera layer'sOVERSCANframe. Its own module, and unit-tested (__tests__/tutorialGeometry.test.ts), because the obvious spelling — negative PERCENTAGE margins — is right in CSS and wrong in Yoga (a percentage vertical margin resolves against the parent's WIDTH in CSS, its HEIGHT in Yoga), so it measured exact in the browser parity harness and was 8-12% of the screen out on the simulator.mobile/src/components/onboarding/TutorialBeatDots.tsx— the flow's ONE five-beat dot strip, shared by/onboarding-tutorial(beats 1-4) and/onboarding-reveal(beat 5).mobile/src/components/onboarding/TUTORIAL_COLLECTION.ts—resolveTutorialCollection, step 3's real, partly-filled collection, and the photo/next-stop the card needs.mobile/src/components/onboarding/TutorialChallengeCard.tsx— step 3's illustration: the shippedHero+ProgressRing, in an inert framed card.mobile/src/components/onboarding/WalkDistance.tsx— step 2's counting-down distance, derived from the walk's shared value on the UI thread rather than from React state.mobile/app/onboarding-reveal.tsx— the reveal screen shell: the bounded wait state, thePageLoader/Skeleton-basedRevealSkeleton, andhandleContinue's drain-both-queues-then-mark-seen-then-navigate order.mobile/src/components/celebration/PatchUnlockReveal.tsx— the whole-pass reveal content (0/1/N-patch states, the fan, the nesting sentence, the achievements row). Since the 2026-09-21 revert/onboarding-revealis its ONLY consumer — the celebration screen no longer mounts it. Owns no navigation beyond itsonContinue/onOpenAchievementscallbacks; this route passescontinueLabel="Start exploring"(the lab's own label for the flow's last beat) andextraContent(the fifth beat dot), and takes the default Continue idpatch-unlock-reveal-continue. Scrollable, so a 5-6 deep nest cannot push Continue off a small screen.mobile/src/components/permissions/permissionRouting.ts—resolveOnboardingRoute,OnboardingRouteInput,OnboardingRoute.mobile/src/components/permissions/PERMISSION_SCREENS.ts—eyebrowFor(step, total), used by the two permission screens to derive "Final Preparation · N of 2" from the steps actually being shown rather than a hardcoded literal (see Edge cases).mobile/src/providers/OnboardingProvider.tsx— the first-run router that sends a newly-chosen auth path into the chain, and the standing-condition-plus-once-only-latch logic for a guest who later creates a real account.mobile/src/utils/celebrationGate.ts—CELEBRATION_BLOCKED_ROOTS,canCelebrateOnScreen.mobile/src/components/achievements/AchievementDeck.tsx—SummaryCard, thequeue.length > 2branch,dequeueAllAchievements.mobile/research/onboarding-tutorial-lab.html— the design source (variant C, "Live Signal"), ported intoTutorialMap.tsx/PatchUnlockReveal.tsxfor geometry and content, not for styling (CLAUDE.md's@/theme-only rule applies to the shipped screens).
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:
- the first-run resolver,
resolveOnboardingRoute(mobile/src/components/permissions/permissionRouting.ts); and - the standing condition —
AuthGate'sreferralOwed(mobile/app/_layout.tsx) andOnboardingProvider'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
- The step counter is derived, not hardcoded.
PERMISSION_SCREENS.ts'seyebrowFor(step, total)replaces a former literal'Final Preparation · 1 of 2'— becauseresolveOnboardingRouteskips an already-granted permission, a user who arrives with location already granted (e.g. via another route) would otherwise see only "2 of 2" and never a "1 of 2," which reads as a step having been silently dropped. Skipon the tutorial skips straight tofinish()— it does not mark any individual step seen, it just runs the same routing decision as reaching the end of step 3 normally.- The tutorial's boundary crossing always succeeds. There is no way to fail or time out step 2 — the walker crosses on a fixed schedule (nominally ~4.2s, reduced-motion ~260ms) with a guard timer as a hard backstop, so the lesson always completes even under a suspended render loop. This is deliberate: the step teaches a mechanism, not a task the user can get wrong.
- A user with nothing nearby still sees the reveal, in its empty state, and that is
deliberate rather than an oversight — see Surfaces. A returning user never sees it at
all: the resolver's gate is
firstRunRevealSeen, which is backfilled true on upgrade. /onboarding-revealwaits for the unlock pass, not for a clock. It holds its placeholder only while the launch's first unlock pass could still put something on the screen, and stops the moment any of four things is true: a patch has already unlocked; the pass has resolved either way (LocationProvider'sinitialPatchCheckDone); foreground location was declined, so no pass will ever run; or theWAIT_FOR_PASS_MSceiling elapses. In practice that is well under a second. The ~8s ceiling remains, chosen from user-research panel data (median detection 2-3s, slowest 15s), but it is now the last resort rather than the wait itself — it previously ran flat, regardless of what the pass had already reported, so the last beat of onboarding was eight seconds of skeleton followed (for most users) by the empty state, which reads as a screen with no last slide. A patch that unlocks after the ceiling elapses still lands normally: it stays queued and fires the ordinary/celebrationmodal once the user reaches an un-blocked screen at home.- Nobody sees
/onboarding-referralwhile the capture step is switched off (see Configuration and flags). The guest carve-out below is dormant along with it, and is recorded because it is still load-bearing if the step is re-enabled: a guest never saw the step by design (seeresolveOnboardingRoute's own doc comment) —Referral.refereeIdis UNIQUE, so a code entered as a guest burns the referrer's one claim slot — andreferralAskedis deliberately left untouched for a guest so the step is still offered once if that device later creates a real account.
What this feature does NOT do
- The tutorial's boundary is illustrative, not the user's real geofence. Step 2's
polygon (
TutorialMap.tsx'sPOLY) is a fixed, invented shape drawn purely to teach the concept of containment — it is not derived from any real park/monument/city boundary, it does not correspond to any actual patch, and crossing it unlocks nothing. The real unlock mechanism (server-sideST_Containsagainst actual polygon geometry) is documented entirely in unlock-and-location; this screen only illustrates the idea "you have to be inside the line." - The tutorial does not grant any patch itself. No step of
/onboarding-tutorialcollects, unlocks, or awards anything — not the illustrative boundary crossing, not the marquee sky, not the collections step. Whatever appears on/onboarding-reveal(if anything) was unlocked by the ordinary, independent location-tracking pipeline running in the background while the user granted permissions, exactly as it would for any other arrival — the tutorial and the reveal are sequential but causally unrelated. - The marquee sky is a fixed set, not personalised. The six National Park patches shown in step 1 are the same for every first-run user on every device, regardless of where they are, because step 1 runs before the location permission ask and there is no fix to be near yet. It is not "patches near you," it is not selected from the user's eventual home region, and it does not change based on anything about the account.
- There are no standalone permission screens any more.
/onboarding-locationand/onboarding-notificationswere deleted on 2026-09-20; the ask is step 4 of the tutorial, which reuses their exact copy constants (mobile/src/components/permissions/PERMISSION_SCREENS.ts), their neutral CTA wording and their declined-state fallback to camera-roll import. Those deep links no longer resolve./location-permission— the separate "location is off, open Settings" explainer reached from elsewhere in the app — is a different screen and still exists. - Step 4 does not simulate the permission ask. It opens the real OS dialogs, one per press, in sequence — it is not a preview, a pre-prompt or a soft ask that could be answered inside the app. There is deliberately no "Maybe later": a user who does not want a permission declines it in the system dialog, and the flow carries on either way.
- There is no step counter on step 4. The old screens said "Final Preparation · 1 of 2"
/ "· 2 of 2", derived so an already-granted permission did not leave a phantom step. With
one permission step there is nothing to count, so the eyebrow is the honest name
READY(the lab's own kicker) andeyebrowFor()is no longer called by any shipped screen. - It does not show ANY celebration modal during the flow, and it does not fire the
unlock notification either. Those are two separate gates in
mobile/src/utils/celebrationGate.ts(canCelebrateOnScreenfor the modal,canAnnounceUnlockfor the notification) and closing one never closed the other — "New Patch Collected!" banner-dropped over tutorial step 1 for exactly that reason. The reveal is the one place the first unlock is shown, and it DEQUEUES the pass on Continue, so nothing replays at home either. - It does not batch the ordinary post-onboarding celebration. Outside this flow a nested arrival is one modal per patch (see unlock-and-location). Showing a whole pass on one screen is something only the first-run reveal does.
- It does not re-run once
tutorialSeenis true on that device, except across a genuine reinstall (which clears local storage) — signing out and back in on the same install does not replay it, since the flag is device-persisted, not per-session. - It does not ask how you heard about Scout. There is no "How did you hear about us?" step, no signup-source question and no invite-code field anywhere in onboarding — not on first run, and not later for an existing account that was never asked (that second path was a real one and is closed too). The screen that asked it still exists in the codebase, switched off and reachable only by deep link; no user route leads to it, and nothing records a signup source during onboarding any more. Referral codes are still redeemable elsewhere — see referral-and-invite.
- It does not gate anything behind a server feature flag or staged rollout — the entire flow is live for 100% of first-run devices on every shipped build. The one thing that is gated is gated by a source constant, not a rollout: the dormant referral capture step (see Configuration and flags).
Tests that cover it
mobile/screen-tests/onboarding-tutorial.test.tsx— the marquee sky rendering with no location fix; the catalogue-fallback falsification (missing marquee ids → first six catalogue patches, asserted against a different fixture set than the happy path); the placeholder sky on an unresolved catalogue, paired with its opposite (no placeholders once the catalogue is there); the boundary lesson copy and the "nothing fires" pre-crossing state; a falsification that the CTA cannot be used to claim an unlock before the crossing actually happens; the CTA un-gating once the (reduced-motion) crossing timer fires; the crossing firing from the guard-timer fallback alone withsetIntervalstubbed out and reduced motion forced off; step 3 naming a REAL collection at a partial count, paired with the cold-catalogue case that shows no name and no count at all; the three crossing beats present (and absent beforehand), plus their ORDER with motion on — the perimeter lights a beat before the interior fills; and the sky's resting opacity with motion ON, which is the branch the suite otherwise never exercises (the screens project forces reduced motion globally, and that is exactly how all six patches once shipped invisible to every real user).mobile/screen-tests/onboarding-reveal.test.tsx— naming every patch from a real onboarding unlock; the unlock queue AND the achievement queue both being empty after Continue (the replay-prevention regression tests); the achievements row tapping through to/achievements;firstRunRevealSeenbeing set on Continue; and the empty state offering a way forward rather than dead-ending.mobile/screen-tests/patch-unlock-reveal.test.tsx—PatchUnlockRevealitself, directly: the 0/1/N-patch states, the no-art fallback glyph, and the achievements row — see unlock-and-location for the full list.mobile/src/utils/__tests__/celebrationGate.test.ts— theCELEBRATION_BLOCKED_ROOTSdrift test walking every registeredonboarding-*route; the standard suppressed-until-tutorialSeenand blocked-root cases.mobile/src/components/permissions/__tests__/permissionRouting.test.ts—resolveOnboardingRouteper branch, including the guest path, a test pinning that no branch can ever resolve to a standalone permission route, the pair that pins the reveal ruling (a first-run user with NOTHING unlocked still routes to the reveal, and a returning user never does), and an exhaustive sweep of all sixteen input combinations proving the resolver can never reach/onboarding-referral.mobile/src/components/referral/__tests__/referralCapture.test.ts— the switch itself:referralCaptureOwedis false for every input, and the screen, its screen test and its deep-link registration all still exist (dormant, not deleted).mobile/src/providers/__tests__/OnboardingProvider.test.tsx— both halves of the standing condition: with the real switch it never navigates to the referral screen, and with the switch simulated on the round-1/2/4 loop-and-latch regressions still hold. The second half is the falsifier for the first.mobile/src/components/onboarding/__tests__/TUTORIAL_COLLECTION.test.ts— the resolver: the lab's Boston row at 4/11, the size-band fallback, never-complete at seven different collection sizes,nullrather than an invented name, and determinism.mobile/src/domain/__tests__/store.firstRunRevealBackfill.test.ts— the persistmergebackfill, driven through the real persist config: an already-onboarded blob is treated as having seen the reveal, a mid-first-run blob is not, and an explicit value is never overwritten.mobile/src/components/achievements/__tests__/AchievementDeck.test.tsx— "collapses more than two achievements into one summary card" paired with "still plays individual cards at two or fewer" (the falsifying opposite branch); the five-achievement "naming the first three" case.mobile/src/domain/__tests__/partialize.test.ts— assertstutorialSeenis in the persisted-field whitelist, which is the mechanism behind the reinstall-replay behavior described in Status/Edge cases.mobile/screen-tests/onboarding-tutorial.test.tsx(step 4 block) — the assertions migrated from the two deleted screen tests, plus the ones the move made necessary: the background disclosure is on screen and no permission has been requested yet (the ORDER, not merely the presence of the text); the CTA label is neutral in both stages; the real location request fires before the real notification request and not alongside it; declining location swaps the row to the camera-roll alternative; the notification row promises only the unlock banner and never a proximity notification; the flow advances to the reveal on its own after the second answer, paired with its opposite (it must NOT leave after only the first);Skiplands ON step 4 rather than past it; and the whole step renders with motion ON, so the 5.1.1(iv) disclosure can never be an animation side effect. Each of the three App Review assertions was verified against deliberately broken code (the lab's "Turn on location" label, the lab's short row subtitle, and aSkipthat calledfinish()) and fails on it.mobile/screen-tests/onboarding-referral.test.tsx— the pre-existing, unchanged referral screen.mobile/maestro/tests/onboarding-tutorial.yaml— the one Maestro E2E for this feature (tags:onboarding,smoke). Cold-boots a cleared device throughmobile/maestro/lib/launch-fresh.yaml, provisions a throwaway account, then walks all four tutorial steps — asserting each step's copy, the live boundary readout flipping to "Inside — the patch unlocked.", the collections counter reaching 5 / 5, and on step 4 the background-location disclosure being on screen with a neutral CTA before either OS dialog is opened — then both real OS dialogs in order, and the unprompted hand-off to the reveal. The shared helpermobile/maestro/lib/grant-permissions.yamlskips the LESSON and drives step 4's two presses plus the reveal's Continue for every other flow in the suite, which only needs to reach home.
Open questions
- The exact wording/order a screen reader announces across all four tutorial steps beyond
the
accessibilityLabelstrings inSTEP_A11Y(onboarding-tutorial.tsx) was not verified against a real VoiceOver/TalkBack session — only that each step's lesson is independently stateable in text, per the accessibility requirement in the design spec.