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):
- 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.
- The boundary — a walker crosses an illustrated, invented park fence; nothing fires until the line is crossed.
- 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.
- The account step (added 2026-10-24, redesigned 2026-10-25; a quiet "Why do I need an
account?" link opens the
/why-accountsheet 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. - The permission ask — location, then notifications, with the disclosure on screen before either OS dialog.
- The reveal (
/onboarding-reveal) — the first real unlock, or the honest empty state. - 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.
deviceOnboarded(persisted,mobile/src/domain/store.ts:904) is set true bysetTutorialSeen(true)(store.ts:3110) and no action ever clears it. It survives sign-out and Delete Account (reset()re-establishes it,store.ts:3288), and the persistmergebackfills it fromtutorialSeenfor a device that finished onboarding before the field existed (store.ts:3404).- Routing is one pure function,
resolveAuthRedirect(mobile/src/providers/authRedirect.ts:68-79), run byAuthGate(mobile/app/_layout.tsx:386-421). Every signed-out device goes to/onboarding-tutorial, step 1 (it may stay on/auth/email, which the account step pushes). That includes a device that has onboarded, after Sign out, Delete Account or a lost session: Alan, 2026-10-25, replacing the 2026-10-24 rule that sent it to the standalone/auth("the existing screen just looks weird").hasChosenAuthPathno longer routes (authRedirect.ts:59-63). - An onboarded device signing in on step 4 goes straight home. The tutorial reads
deviceOnboarded: when a session lands on the account step of a phone that has finished onboarding before, it does not advance to the permission ask, it callsfinishandgoToOnboardingRoutetakes it to the app (or a carried deep link) (mobile/app/onboarding-tutorial.tsx:294-303; the on-screen step stays the empty account step for that frame,:160-161). On/auth/emailthe same sign-in is moved into the app byAuthGate's signed-in-on-authbranch. - A sign-in on a never-onboarded device (Google, Apple, password, email code; and guest
when
guest_modeis on) callsonInteractiveSignIn()(store.ts:3127, called frommobile/src/providers/AuthProvider.tsx:638,:768,:842,:859). It replacedbeginOnboarding(). On a never-onboarded device it setsonboardingPendingand clearstutorialSeen/firstRunRevealSeen. On an onboarded device it goes straight in:onboardingPendingfalse,firstRunRevealSeentrue,setTutorialSeen(true). No tutorial, no reveal. OnboardingProvider(mobile/src/providers/OnboardingProvider.tsx:140-163) opens the tutorial only for a signed-in account withonboardingPending, never navigates while the user is already ononboarding-tutorial(the tutorial advances itself), and from/auth/emaildoesrouter.dismissTo('/onboarding-tutorial').- A carried deep link. A fresh install opened by an allowlisted deep link
(
/submit-patch,/compass,/browse-patches;mobile/src/lib/auth-return-to.ts:8-12) is parked in the transientpendingReturnTo(store.ts:921, not persisted) while the tutorial runs, and honoured once on the terminal hop to home (mobile/src/components/permissions/onboardingNavigation.ts:20-36). - A session RESTORED at launch never sets
onboardingPending: a signed-in account that is not owed the tutorial but hastutorialSeenfalse (e.g. an iOS reinstall whose keychain survived) is marked onboarded instead, so celebrations are not gated off. The flag survives a kill, so someone who quits mid-tutorial resumes it after relaunch, and a signed-in user who relaunches into the tutorial starts on the permissions step.
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:
- v1.1.112 and later: the five-step tutorial with the account step as its front door (intro, boundary, collection, account, permissions), the reveal, and the camera-roll beat. This doc describes this version.
- v1.1.110 and v1.1.111: the same without the account step; the sign-in screen came first.
- v1.1.99 to v1.1.109: the five-step Starter Pack (pack, rarity
cards, boundary, collection, permissions), then the reveal's "Start exploring" straight
home, with no camera-roll beat. It landed in the code on 2026-10-02 (
8984b0db). - Builds before 1.1.99: the previous four-step map tutorial.
The permission ask, the reveal's states and the routing are the same in all three.
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 route with five internal steps (STARTER_STEPS,starterPack.ts:25-31), a shared footer CTA (tutorial-next) whose label and enabled state belong to the step (onboarding-tutorial.tsx:358-367; absent on the account step), and aSkipcontrol (testIDtutorial-skip) on steps 1-3 that jumps to the account step, or to permissions if the user is already signed in (handleSkip,:348-352;accountOrAsk,:200). It skips the lesson, never the sign-in and never the permission ask, and is not rendered on the account or permissions step (showSkip,:357). Each step's body carries the testIDtutorial-step-<id>(intro,boundary,collection,account,permissions). A signed-in user who opens the route starts on permissions (:144).- One real patch (
mobile/src/components/onboarding/starter-pack/IntroStep.tsx; since v1.1.110) — one real patch, Boston Common, hardcoded asINTRO_PATCH(see Data model; since 2026-11-04 it no longer waits on the catalogue), rises over a soft, round photo of its own place, then floats. Under it: the patch's name (testIDtutorial-intro-name) and one line with an inlineRarityBadge(tutorial-intro-rarity) and the patch's own rarity note (IntroStep.tsx:115-128). Copy: eyebrow "Welcome to Scout", headline "Every real place has a patch.", body "Parks, landmarks, cities, even whole states each have one. This is <name>'s." (IntroStep.tsx:130-137; a name ending in "s" takes a bare apostrophe, "Massachusetts'"). A cold catalogue changes nothing here: the patch, its name, its rarity line and its art are all drawn fromINTRO_PATCH. The CTA reads "How do I get one?" and is never gated. About 550 ms in, the route fires a ring and burst into the ember field from the art, with a haptic; neither runs under reduced motion, where the patch is simply there (onboarding-tutorial.tsx:98,:210-218). The step is aScrollView(testIDtutorial-intro) that centres its content when it fits and scrolls inside its own bounds when it does not, so nothing slides under the CTA in a 375×667 window (see Edge cases). The art carries testIDtutorial-intro-art. This replaced the sealed "Field Pack" (tap to tear open) and the four face-down rarity cards (one real patch per tier, "Preview · not yours yet", flip all four to continue) that were steps 1-2 from 2026-10-02; both are what v1.1.99 to v1.1.109 show. - The boundary (
starter-pack/BoundaryStep.tsx) — the lesson. An illustrated map (grid lines and road strokes) with a park-shaped five-sided fence drawn dashed, a patch pin at its centre wearing the intro patch's art (onboarding-tutorial.tsx:391), and a walker moving in a straight line, at a constant pace, from outside the fence to just past its middle — a 0.5 s pause, then a 3.4 s walk (BoundaryStep.tsx:32-34). Copy: eyebrow "How you earn one", headline "Go there. That's the whole trick.", body "Every patch has a real boundary. Cross it and the patch is yours. Standing next to it doesn't count." (BoundaryStep.tsx:146-149). A live readout counts the feet to the line down (it starts at roughly 950 ft on an invented scale — see Data model) beside the label "ft to the line"; at the crossing the readout becomes "Inside" beside "the patch unlocks" (BoundaryStep.tsx,WalkReadout), the fence remounts as the solid lit outline (tutorial-boundary-fence-inside— remounted rather than re-propped because react-native-svg did not clear the dash pattern on device), the pin grows, the patch lifts out of the fence as a card (tutorial-boundary-card-up), and an ember ring, a burst and a success haptic fire. Like the rest of the screen, the map runs full-bleed under the floating dots/Skip bar. The crossing lands about 2.55 s after the step appears (260 ms under reduced motion). The CTA reads "Next" and is disabled until the crossing (onboarding-tutorial.tsx:220-230,:330,:366). The fence is invented and nothing is awarded here. - The collection page (
starter-pack/CollectionStep.tsx) — eyebrow "Sets", headline "Then you fill the page.", body "Every patch belongs to a set. <Collection> has N. Finishing sets is the game." (CollectionStep.tsx:35-41). Below it, a page card for a real collection from the synced catalogue (Boston when the catalogue has it — see Data model): a progress ring and acollected/totalcount with "n to go", both from the user's real collection count — so a brand-new account sees 0 — and a grid of slots, five to a row, up to 20. A member the user has collected shows its art in a solid frame; an uncollected one is a numbered dashed outline (patch art is never shown greyed out to mean "not yours"); and the first uncollected member by name is lit gold, in full-colour art, with a "Next" tag and the line "First one to chase: <name>" (CollectionStep.tsx:64-92). With a cold catalogue the page keeps its shape — 15 numbered empty slots, a placeholder bar where the name goes, no count — and never invents a name (CollectionStep.tsx:23,:48-52). About 900 ms in, the "Next" slot gets a small ring and burst in the ember field (onboarding-tutorial.tsx:232-237). The CTA reads "Got it". This replaced the previous tutorial's partly-filled illustration (Boston drawn at 4 of 11, 36%), which showed a progress the user did not have. - The account step (added 2026-10-24;
AccountStepinonboarding-tutorial.tsx:132renderingAccountPanel,mobile/src/components/onboarding/account/AccountPanel.tsx; testIDtutorial-step-account, panelaccount-panel). Redesigned 2026-10-25 to lab Round 5 · B, "The patch you met", after Alan found the R4b Ledger "way too cluttered". It shows the Field Museum patch (Alan, 2026-10-25;ACCOUNT_PATCHinstarterPack.ts, hardcoded since 2026-11-04 and owned byAccountPanelitself, so/authand this step always draw the same one; testIDaccount-art) — its own pick, NOT step 1's patch, which stays Boston Common. Behind it, step 1's look: a soft disc of the museum's own photo (PlacePhotoDisc,src/components/onboarding/PlacePhotoDisc.tsx, shared with IntroStep; testIDaccount-photo), at step 1's resting 55% opacity. The ember field pops from it 400 ms in (ACCOUNT_POP_MS,:108,:238-244), and there is no haptic. Then the headline "Keep every patch you earn.", one sentence "A free account saves your patches, badges, posts and comments on any phone.", and a gold, bold, underlined "Free, no subscription." (account-free). A cold catalogue still shows the Field Museum (hardcoded). The words are data inaccount/accountCopy.ts:13-17. The copy is a claim about the server: those four things are stored against the account and come back on a new phone. So it never says "everything" about what the account keeps, and never names photos, the album or trips, which stay on the device. The eyebrow, the four-row list and the "On any phone" line were removed in the redesign. Under it, the same entrances/authuses (mobile/src/components/auth/AuthEntrances.tsx, view-modelauthLandingViewModel.ts): Apple, Google, email link, password (theEXPO_PUBLIC_TEST_SIGNINvariant), and the guest link only whenguest_modeis on (it ships OFF, so the link is hidden), under the small ruled label "Sign in or create account" (board ticket 240a9344). Since 2026-10-25 (lab Round 7 · B) every entrance is a ROUND, LOGO-ONLY button in one centred row (EntranceCircle, AuthEntrances.tsx): Apple (white, black logo), Google (Google's dark theme) and email (a gold envelope on Scout's raised surface); the provider colours arecolors.signInin@/theme. Each keeps the full "Continue with …" accessible label. Android has no Apple, so its row is Google and email. The dev sign-in card is no longer here: it is the hiddenscout://auth/devroute (dev builds and Maestro only; see auth-account-and-profile.md). The italic "One tap to sign in — or start your field guide." under the rule was removed 2026-10-25. No Skip and no footer CTA (ctaLabelis null,:395); the step is left only by signing in. Whichever entrance signs in, the step advances in place to permissions, keyed onuser, so a guest session would clear it too. The screen moves in the same render that sees the user (shownIndex,:150), so no empty account frame is ever painted; an effect then makes it stick (:244-248). The web demo's auto-created account mints only when this step mounts, not on step 1 (:115-125). A user who is already signed in never sees the step (:200). - The permission ask (
starter-pack/PermissionStep.tsx; testIDtutorial-permission-rows) — no illustration: eyebrow "Last thing", headline "Now let's earn one for real." (PermissionStep.tsx:22-23), the location disclosure as body copy, and two rows, Location and Notifications, each an icon, a name, a one-line subtitle (PermissionScreenConfig.rowSubtitle— "So a patch fires the moment you arrive" / "A quiet signal when one unlocks") and a ring that fills and pops a tick when the OS answers yes (testIDstutorial-perm-location-done/-pending, and the same for notifications). A yes also fires an ember ring and gold burst from that check, with a success haptic (onboarding-tutorial.tsx:250-262, the grant effects →popAt); a decline fires nothing. The row the next press is about is highlighted. One button, pressed twice: press one opens the real OS location dialog (useLocation().requestPermissions()thengetPermissionStatus()), press two the real notification dialog (askNotifications('onboarding')) (onboarding-tutorial.tsx:283-316); once both are answered the CTA reads "Earning your first patch…", disabled, and the screen advances to/onboarding-revealon its own after 700 ms (90 ms under reduced motion), with no third press (onboarding-tutorial.tsx:318-322).markPermissionsPrompted()fires on entering the step, so the home-screen auto-prompt (src/hooks/usePermissionPrompt.ts) never re-asks (onboarding-tutorial.tsx:264-268). The whole step is one accessibility element whose label carries the headline, the disclosure and both rows (PermissionStep.tsx:53-56). 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 rendered as body copy from the moment the step mounts (testIDtutorial-perm-location-disclosure), before anything can open the dialog (the step shares the route's 320 ms fade-in, but the copy never waits on an animation to exist); the rows' one-liners never replace it; (b) the CTA label is neutral —LOCATION_SCREEN.ctaLabel/NOTIFICATIONS_SCREEN.ctaLabel, both "Continue" (onboarding-tutorial.tsx:107-111), never "Turn on location", which would pre-commit the user's answer on Apple's behalf; (c) declining location still leaves a real alternative — the disclosure paragraph swaps toLOCATION_SCREEN.denied.body, which points at camera-roll Import (/import-modal) (PermissionStep.tsx:42-48). There is no in-app skip past this step.
One shared ember field runs under all five steps —
useEmberField, the same Skia engine as the Ember Surge unlock celebration — drawn behind and in front of the content (onboarding-tutorial.tsx:136,:373,:416), resting at a quiet ambient level (fx.ts:15) between beats. Every burst and ring is decoration only: nothing on screen waits for an effect, and none of them run under reduced motion (fx.ts:34). Since 2026-10-19 the field carries onlypopAtbeats (the intro patch, the boundary crossing, the next collection slot, each granted permission); the Legendary dim, the god-rays and the pack's flash went with the cards and the pack.One dot strip spans the whole flow (
mobile/src/components/onboarding/TutorialBeatDots.tsx), not one set per screen: beats 1-5 are the tutorial's steps, beat 6 is the reveal and beat 7 the camera-roll beat, both on/onboarding-reveal(TUTORIAL_BEAT_COUNT = 7,TUTORIAL_REVEAL_BEAT = 5,TUTORIAL_RETRACE_BEAT = 6,TutorialBeatDots.tsx:25-30). Every account walks all seven, so the strip always draws seven dots (TutorialBeatDots.tsx:39; rendered atonboarding-tutorial.tsx:379,onboarding-reveal.tsx:261,:331, and inFirstUnlockSurge.tsx:117). testIDs aretutorial-step-dot-<i>, suffixed-onfor the active beat and-donefor the ones behind it.- Deep link
scout://onboarding-tutorial, registered inmobile/src/dev/deepLinkRoutes.ts.
- One real patch (
-
/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. When something unlocked (since 2026-10-02) it plays the Ember Surge stage — the sameEmberSurgeStagea normal unlock plays, throughFirstUnlockSurge(mobile/src/components/onboarding/FirstUnlockSurge.tsx): the batch's FIRST patch (innermost —selectHeadBatchsorts by polygon reach, so the place actually crossed into, e.g. Freedom Trail, not Massachusetts) surges with its tier's choreography, and every other patch of the same arrival sits under it as "Also unlocked" (art + name, testIDfirst-unlock-also), then the shared count line and collection bar (CelebrationTally), the dot strip and the Next button (the route passescontinueLabel="Next",onboarding-reveal.tsx:290;FirstUnlockSurge's own default, "Start exploring", is never used by onboarding). The stage is driven by a narrowEmberSurgeModelplus arenderFooterslot, so the celebration screen and this reveal share one implementation. Otherwise (waiting, or nothing unlocked) it 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:165).- The camera-roll beat (since v1.1.110; body
mobile/src/components/onboarding/starter-pack/RetraceStep.tsx, buttons on the route; testIDonboarding-retrace-screen). Follows the reveal for every account, including a device that has already finished an import scan and a returning account whose reveal is skipped. The reveal's button reads "Next" in both of its forms (onboarding-reveal.tsx:290,:332). Pressing it does all of the reveal's exit bookkeeping on that press —onboarding_completed, drain both queues,firstRunRevealSeen,tutorialSeen,onboardingPending = false— and then switches the same route to its'retrace'phase instead of navigating (onboarding-reveal.tsx:170-206).handleContinueruns once (continuedRef). For part of 2026-10-19 the beat was offered only on a device that had never finished an import scan; Alan removed the gate the same day because the beat went missing on exactly the devices that had scanned once, and he wants everyone to see it (onboarding-reveal.tsx:34-46). The beat itself: eyebrow "One more thing", headline "Fill in where you've already been.", body "Scan your camera roll and Scout adds the patches for the places in your photos, dated to the day you were there." (RetraceStep.tsx:88-94). Under it, up to three example photos (resolveRetraceExamples, see Data model) fan in; a scan line sweeps across them and each photo's patch art pops out with a tick and a light haptic; a tag reads "Example photos" (RetraceStep.tsx:96-119). They are catalogue photos of real places, not the user's photos: the camera roll cannot be read before the permission the import screen asks for. With no qualifying catalogue photo the illustration is left out. A two-row ledger follows (RetraceStep.tsx:121-134): the first row isprivacyCopyFor(cloudAlbumHasAccess)'sreadLead+retraceLead(mobile/src/domain/cloudAlbumCopy.ts) — "Location & date only. Your photos stay on your device." without cloud-album access, "Location & date first. Backed up to your cloud album." with it (testIDonboarding-retrace-privacy); the second, "You choose. Nothing is added until you pick it." Like the intro, the body is aScrollViewthat centres when it fits and scrolls inside its own bounds on a short window. Below it, the dot strip on beat 6, then two buttons (onboarding-reveal.tsx:259-269): "Scan my camera roll" (onboarding-retrace-scan) routes home throughgoToOnboardingRouteand thenrouter.push('/import-modal')on top — only when the resolved route is/(drawer); "Maybe later" (onboarding-retrace-later) routes home and opens nothing else (leave,onboarding-reveal.tsx:158-168). These two buttons are the only way out of onboarding:leaveis called from nowhere else. The beat never asks for photo permission itself;/import-modaldoes (see camera-roll-import). There is no Skip and no back gesture on it.
- The camera-roll beat (since v1.1.110; body
-
/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, or the camera-roll beat after it, 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: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 —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:44) 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)andsetTutorialSeen(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,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. The reveal shows one subordinate row instead — "Also earned · N achievements" on the Ember Surge reveal, "Also unlocked · N achievements" on the empty state (testIDpatch-unlock-reveal-alsoon both) — 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 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/contenthas 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 instarterPack.ts— see Data model. - 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 asetTimeoutatcrossingAtMs(reduceMotion)(onboarding-tutorial.tsx:220-230) —WALK_DELAY_MS + crossingFraction() × WALK_MSwith 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 mssetIntervalclock insideWalkReadout, so the countdown re-renders only itself (BoundaryStep.tsx:163-176). - 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 instarter-pack/fx.ts—AMBIENT(the resting level),centerOfandpopAt(ring + burst, plus a whole-field recolour for Rare and up) — measure the target element withmeasureInWindowand draw into it.popAtreturns immediately under reduced motion. The step machine never reads anything back from the field. (legendaryAtandsettlewere deleted on 2026-10-19 with the rarity cards.) - 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.tsxheader comment,:34-46):- A kill on the camera-roll beat lands home on next launch.
firstRunRevealSeen,tutorialSeenandonboardingPendingare 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 samesetPhase('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).
- A kill on the camera-roll beat lands home on next launch.
Data model
tutorialSeen: boolean(mobile/src/domain/store.ts:886) — 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."deviceOnboarded: boolean(mobile/src/domain/store.ts:904) — persisted; this PHONE has finished onboarding at least once. Set bysetTutorialSeen(true)(:3110), never cleared, kept byreset()(sign-out and Delete Account,:3288), backfilled fromtutorialSeeninmerge(:3404). It does NOT route a signed-out device (every one goes to the tutorial); it decides what a sign-in does:onInteractiveSignIn(), and whether the tutorial's account step goes on to the ask or straight home. Test:mobile/src/domain/__tests__/store.deviceOnboarded.test.ts.pendingReturnTo: string | null(store.ts:921) — transient (not inpartialize); an allowlisted fresh-install deep link parked byAuthGateand consumed once bygoToOnboardingRouteon the terminal hop.onboardingPending: boolean(mobile/src/domain/store.ts) — persisted; the tutorial is owed. Set (withtutorialSeen/firstRunRevealSeencleared) byonInteractiveSignIn()(store.ts:3127) on an interactive sign-in/up from a never-onboarded device, cleared by the reveal's Next (or the returning-account skip). On an onboarded deviceonInteractiveSignIn()leaves it false.OnboardingProvideropens the tutorial only for a signed-in account with it set (see Status).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:582) —{ 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 a whole pass at a time too; both act on the single copy, and the reveal's drain must remove everything it displayed or the remainder resurfaces at home as a second celebration. The queue (andachievementQueue) is cleared on an account switch (clearLocalUserData), so a new account's reveal never shows the previous account's unlocks. Full shape and batching rules documented in unlock-and-location.- The tutorial's own state is in memory only. The current step, whether the walker has
crossed and the permission stage are plain React state on the route
(
onboarding-tutorial.tsx:144-162), and the reveal's'reveal' | 'retrace'phase is plain state on/onboarding-reveal(onboarding-reveal.tsx:132) — nothing is persisted, and nothing is written to the server. Leaving the app mid-tutorial and coming back starts it at step 1 (a signed-in user at the permissions step,:144), becausetutorialSeenis still false. Leaving on the camera-roll beat lands home, because by then it is true. STARTER_STEPS(mobile/src/components/onboarding/starterPack.ts:25-31) — the five steps, in order, each anidand the analyticsname:intro/intro,boundary/boundary,collection/collections,account/account,permissions/permissions.ACCOUNT_STEP_INDEX(:35) is where Skip lands for a signed-out user;PERMISSIONS_STEP_INDEX(:36) for a signed-in one and where a sign-in advances to. (2026-10-02 to 2026-10-19 it had five different ones:pack/starter_packandrarity/raritybeforeboundary; 2026-10-19 to 2026-10-24 it had four, no account.)INTRO_PATCH(starterPack.ts) — step 1's one patch, and the art the boundary pin wears: Boston Common (boston-boston-common), hardcoded since 2026-11-04 (Alan: the patch "takes a long time to load"). It used to be resolved from the synced catalogue (resolveIntroPatch, with fallbacks down to any patch with art), so a new install showed an empty stage until/api/sync/contentanswered, about 10 s on prod, and only then started the art download. The constant carriesid,name,patchUrlandphotoUrl(prod's Tinify-optimized S3 objects, copied from the catalogue 2026-11-04),photoBlurhash,rarityand the patch's ownrarityNote. Replacing Boston Common's art in the catalogue does NOT reach the tutorial: update the constant. S3 objects are not deleted when art is replaced, so a stale constant still draws, it never breaks. Rarity itself is Scout's authored tier — see patch-rarity.ACCOUNT_PATCHis the same thing for the account panel (the Field Museum). This replacedSTARTER_PACK_IDS+resolvePackCards, which dealt four cards, one real patch per tier (deleted 2026-10-19).RETRACE_EXAMPLE_IDS+resolveRetraceExamples(patches)(starterPack.ts:79-108) — up to three example photos for the camera-roll beat. Only catalogue patches with both art and a location photo qualify. The preferred ids first, in this order:np-acadia-national-park,boston-boston-common,city-boston; then any other qualifying patch, by name, to fill up to three. Each example carriesid,name,patchUrlandphotoUrl. Fewer than three is fine; none hides the illustration.resolveCollectionPage(collections, links, patches, collectedIds)(starterPack.ts:147-184) — step 3's page. Picks a real collection with at least one synced member:boston(orcollection-boston) when present, else the first collection of 6-20 members by name, else the largest. Returns its name,total(members in the synced catalogue),collected— the user's real count of those members, fromuseCollectedPatchIds(), so 0 for a new account — up toCOLLECTION_PAGE_MAX_SLOTS(20) slots sorted by name, each marked collected or not, andnextIndex, the first uncollected slot (−1 when every shown slot is collected).nullwhen nothing is synced, which the screen renders as a placeholder rather than a fabricated name. (This replacedresolveTutorialCollection, which clamped an illustrated count so the ring was always partly but never fully filled.)- The boundary geometry (
starterPack.ts:192-249) — all in fractions of the map's own box, so the map can be any size:FENCE, a fixed five-point park-like polygon;FENCE_PIN, where the patch art sits;WALK_FROM(outside, below-right) andWALK_TO(just past the middle);isInsideFence(an even-odd point-in-polygon test);distanceToFence;crossingFraction(); andFEET_PER_MAP_UNIT = 3600, an invented scale that makes the map read as a city park about 2,000 ft across, so the readout's feet sound right. None of it corresponds to a real place or a real geofence.
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 five-step route: the step state machine, the footer CTA's per-step label and gating, the timers (intro burst, boundary crossing, collection beat, permission auto-advance),handleSkip, the permission drivers (driveLocation/driveNotifications) andfinish().mobile/src/components/onboarding/starterPack.ts— the pure model:STARTER_STEPS,PERMISSIONS_STEP_INDEX,INTRO_PATCH,ACCOUNT_PATCH,RETRACE_EXAMPLE_IDS+resolveRetraceExamples,resolveCollectionPage, and the boundary geometry (FENCE,FENCE_PIN,WALK_FROM,WALK_TO,isInsideFence,distanceToFence,crossingFraction,FEET_PER_MAP_UNIT). No React, no store — everything the screens decide that is not drawing.mobile/src/components/onboarding/starter-pack/IntroStep.tsx— step 1 (since 2026-10-19): the patch over its place photo, its name, rarity badge and note, the copy, and the scroll view.mobile/src/components/onboarding/starter-pack/BoundaryStep.tsx— step 2: the map, the fence, the walker,WALK_MS/WALK_DELAY_MS,crossingAtMs, andWalkReadout.mobile/src/components/onboarding/starter-pack/CollectionStep.tsx— step 3, the page, its ring and slots, and the cold-catalogue placeholder.mobile/src/components/onboarding/account/AccountPanel.tsx+accountCopy.ts— step 4, the account step (also rendered by/auth); the copy as data.mobile/src/components/auth/AuthEntrances.tsx+authLandingViewModel.ts— the sign-in entrances and their view-model, shared by the account step and/auth.mobile/src/providers/authRedirect.ts+AuthGateinmobile/app/_layout.tsx— the signed-out routing rule (deviceOnboarded).mobile/src/components/onboarding/starter-pack/PermissionStep.tsx— step 5, the disclosure, the two rows, and the check rings whose testIDs the Maestro helper waits on.mobile/src/components/onboarding/starter-pack/RetraceStep.tsx— the camera-roll beat's body (since 2026-10-19): copy, the fanned example photos and scan line, the "Example photos" tag and the privacy ledger. The buttons live ononboarding-reveal.tsx.mobile/src/components/onboarding/starter-pack/fx.ts—AMBIENT,centerOf,popAt: the effects fired into the shared ember field.- Deleted 2026-10-19:
starter-pack/PackStep.tsx,RarityStep.tsx,RarityCard.tsx,rarityCardFit.ts, andfx.ts'slegendaryAt/settle. mobile/src/components/onboarding/starter-pack/copyStyles.ts— the eyebrow / headline / body text roles every step shares.mobile/src/components/celebration/skia/EmberField.tsx—useEmberField/EmberFieldCanvas, the Ember Surge engine the tutorial borrows; documented with the celebration in unlock-and-location.mobile/src/components/onboarding/TutorialBeatDots.tsx— the flow's ONE dot strip (TUTORIAL_BEAT_COUNT,TUTORIAL_REVEAL_BEAT,TUTORIAL_RETRACE_BEAT; always seven dots), shared by/onboarding-tutorial(beats 1-5) and/onboarding-reveal(beat 6, the reveal, and beat 7, the camera-roll beat).mobile/app/onboarding-reveal.tsx— the reveal screen shell: the bounded wait state, thePageLoader/Skeleton-basedRevealSkeleton,handleContinue's drain-both-queues-then-mark-seen order (then switch to the camera-roll beat), the'retrace'phase with its two buttons, andleave, which routes home, opens/import-modalon the scan path.mobile/src/components/onboarding/FirstUnlockSurge.tsx— the reveal when something unlocked:EmberSurgeStagefor the batch's first patch, "Also unlocked" for the rest,CelebrationTally, the dot strip, and the button (continueLabel; onboarding passes "Next", and the component's own "Start exploring" default is unused here).mobile/src/components/celebration/PatchUnlockReveal.tsx— the reveal's waiting and EMPTY states (its 1/N-patch fan is no longer reached from/onboarding-reveal, which playsFirstUnlockSurgewhenever the batch is non-empty). Owns no navigation beyond itsonContinue/onOpenAchievementscallbacks; this route passescontinueLabel="Next"andextraContent(the dot strip on beat 5), 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—LOCATION_SCREEN/NOTIFICATIONS_SCREEN: the disclosure, granted and declined body copy, the neutralctaLabel, and each row'srowSubtitle. The one source for the permission step's words. (It still exportseyebrowFor(step, total), the "Final Preparation · N of 2" builder from the deleted standalone screens; no shipped screen calls it.)mobile/src/providers/OnboardingProvider.tsx— the first-run router that sends a signed-in, owed account into the chain (and stands down on the tutorial route), 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/src/domain/cloudAlbumCopy.ts—privacyCopyFor(hasAccess), the source of the camera-roll beat's privacy line (shared with the import screen).mobile/research/onboarding-gamified-lab.html— the design source for the tutorial: "Round 3 · Streamlined" (R3, picked 2026-10-19) for the four steps, and "R2 · B · Do it, after the reveal" for the camera-roll beat; variant B ("Starter Pack") is the source of the v1.1.99 to v1.1.109 version. Ported for structure and content, not for styling (CLAUDE.md's@/theme-only rule applies to the shipped screens).mobile/research/onboarding-tutorial-lab.html(variant C, "Live Signal") is the source of the previous tutorial and still of the reveal's content and the flow's dot strip.
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
-
Small screens: scroll inside your own bounds. The rarity grid is gone (2026-10-19), but its lesson stays. Its fixed 232pt cards ran off a 375×667 window and under the disabled CTA, and App Review rejected 1.1.100 for it (Guideline 4, "text is truncated") on an iPad running the iPhone-only build in a 375×667 compat window; from v1.1.102 it is a native iPad app (ipad-support), but iPhone SE / 8 / mini are still 375pt-class screens. The fix (a
ScrollViewabove the footer, 2026-10-07) is now the rule for both new bodies: the intro (IntroStep.tsx:80-88, its photo disc sized to about a third of the screen height, between 180pt and 290pt) and the camera-roll beat (RetraceStep.tsx:79-87) centre their content when it fits and scroll inside their own bounds when it does not, so nothing slides under the buttons. Pinned for the intro by "scrolls inside its own bounds rather than slide under the CTA" (onboarding-tutorial.test.tsx). The intro's patch name shrinks to fit two lines rather than ellipsizing (adjustsFontSizeToFit,minimumFontScale0.7, inIntroStep.tsx), since an ellipsized name was half of the 1.1.100 rejection, and its rarity note gets two lines. The example photos' place-name chips on the camera-roll beat take two lines and drop a trailing "National Park" (and similar suffixes) from the name (shortPlaceName,RetraceStep.tsx:42-43). Not yet checked on a device. -
Onboarding never asks for a rating (since 2026-10-27; it asked on leaving from 2026-09-30). The first ask is the first live unlock's celebration (unlock-and-location.md). Pinned by
onboarding-reveal.test.tsx→no rating ask after the first reveal("does not ask after Maybe later", "does not ask on the way to an import"). -
There is no permission step counter any more. The deleted standalone screens said "Final Preparation · 1 of 2" / "· 2 of 2", built by
eyebrowFor(step, total)so an already-granted permission did not leave a phantom step. With one permission step there is nothing to count: its eyebrow is the plain "Last thing" (PermissionStep.tsx:22), and both rows are always shown — each still takes its own press, even for a permission the user granted before onboarding. -
Skipon the tutorial skips the LESSON, not the sign-in and not the permission ask — on steps 1-3 it lands on the account step (setStepIndex(accountOrAsk),mobile/app/onboarding-tutorial.tsx:348-352), or on permissions when the user is already signed in. The account step is left only by signing in, and the permission step only through the real OS dialogs (App Review 5.1.1(iv)). It firesonboarding_skippedwith the step it was pressed on. -
Known limit: an upgrade can show the tutorial again. Before this release, an explicit sign-out reset
tutorialSeen. A device that signed out on an older build therefore reaches the new persistmergewithtutorialSeenfalse, backfillsdeviceOnboardedto false from it, and after signing in on step 4 is shown the permission ask and the reveal again even though that phone finished onboarding once. Nothing is lost. Accepted: there is no older record to backfill from. -
Known limit: a handed-down phone skips the ask and the reveal.
deviceOnboardedbelongs to the phone and survives sign-out and Delete Account, so a new person signing in on a phone that already finished onboarding walks the lesson (every signed-out device does) but goes straight home from step 4, with no permission ask and no reveal. The permissions are the phone's anyway. Accepted in the spec. -
Known limit: a carried deep link is dropped on the email path of an onboarded device. A signed-out onboarded device opened by an allowlisted deep link carries it into the tutorial (
pendingReturnTo), and a sign-in on the account step itself honours it. Signing in through/auth/emailinstead is moved into the app byAuthGate, which reads only/auth?returnTo=, so it lands on home; the parked value is consumed by the next terminal onboarding hop. -
Known limit: a carried deep link plus the reveal's "scan" choice. The reveal's scan button replaces to the carried route (
goToOnboardingRoute) and then pushesimport-modalon top of it (mobile/app/onboarding-reveal.tsx:165-166), so the import screen opens over the carried screen rather than over home. -
Known limit: the deep-link allowlist is three routes. Only
/submit-patch,/compassand/browse-patchesare carried (mobile/src/lib/auth-return-to.ts:8-12), so a patch QR link opened on a fresh install lands on the tutorial and then home. -
The tutorial's boundary crossing always succeeds. There is no way to fail or time out step 2 — the crossing fires on a fixed timer (about 2.55 s with motion on, 260 ms under reduced motion) whether or not the walker animation actually ran, so the lesson always completes even under a stalled render loop. This is deliberate: the step teaches a mechanism, not a task the user can get wrong.
-
Only the boundary step is gated. Step 2's CTA stays disabled until the crossing fires. Step 1 ("How do I get one?") and step 3 ("Got it") are never gated; the account step has no CTA and leaves on a sign-in; the permission step leaves on its own after its two presses. (Before 2026-10-19 the rarity step was also held until all four cards were flipped, and the pack left on its own after its tear.)
-
The intro and account patches are hardcoded, so they can drift from the catalogue.
INTRO_PATCHandACCOUNT_PATCHare copies of prod's rows (2026-11-04). If either place's art, photo, tier or note changes in the catalogue, the tutorial keeps the old one until the constant is updated. It never breaks: replaced S3 objects are not deleted. The boundary pin wearsINTRO_PATCH's art, so the two always match. All four images load withcachePolicy="memory-disk", so the pin and/authreuse what step 1 and step 4 already decoded. -
A cold catalogue still renders every step. The intro and the account step are unaffected (their patches are hardcoded); the collection page keeps its shape with 15 numbered empty slots, a placeholder bar instead of a name, and no count or "Next" line (
CollectionStep.tsx:23,:48-60); the camera-roll beat drops its example photos and keeps its copy, ledger and buttons. -
The camera-roll beat is shown to everyone, every time onboarding runs, including a device that has already finished an import scan (it may have taken photos since) and an existing account signing in again. Onboarding no longer reads
importScanCompletedAt. -
A kill on the camera-roll beat does not replay anything. Onboarding is already marked done when the beat appears, so the next launch lands home (see How it works, item 9).
-
"Scan my camera roll" opens the import screen only when the next route is home. If the resolver returned anything other than
/(drawer)(today only the dormant referral step could),leave('scan')routes there instead of pushing/import-modal(onboarding-reveal.tsx:165-167). -
Big collections show their first 20 members. The page's slots cap at
COLLECTION_PAGE_MAX_SLOTS(20, by name); the count still says the true total. The "Next" slot is chosen among the slots shown, so a user who has every shown member gets no "Next" line even if a member past the twentieth is still uncollected. -
The collection page is the same collection for everyone — Boston whenever the catalogue has it — not one near the user (there is no location fix yet). Only the count and the "Next" slot are personal: a user who already collected Boston patches (an existing account signing in — they see the tutorial too) sees their real count and the next one they lack.
-
A user with nothing nearby still sees the reveal, in its empty state, and that is deliberate rather than an oversight — see Surfaces. But inside the US, "nothing nearby" almost never happens: every state patch has a real boundary and the first unlock pass checks them like any other place (polygon containment,
tracker.ts), so a US user who grants location and whose pass completes always unlocks at least their own state. The empty state is what a user sees when location is declined, the pass doesn't finish in time, or the phone is outside every state boundary (abroad). A returning user never sees it at all: the resolver's gate isfirstRunRevealSeen, 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— per account: a second account signed up on the same launch gets its own pass and its own wait, rather than inheriting the first account's "done"); 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
- There is no guest path in the tutorial. Guest mode is fully built (the
continueAsGuestentrance,showGuestMode, the guest session) but sits behind theguest_modefeature flag, which ships OFF (mobile/src/config/feature-flags.ts:15), so the account step shows no "Continue as guest" link. It is hidden, not deleted. - There is no "I already have an account" entrance. The account step offers the same sign-in methods for a new and an existing account; a returning user on a fresh install signs in there like everyone else and goes through the lesson once. There is no separate shortcut around the tutorial for a fresh device.
- The account step cannot be skipped. Skip stops at it. Nothing in the tutorial lets a signed-out user reach the permission ask or home without signing in.
- A phone that has finished onboarding never re-asks. Signed out, it walks the lesson
again like any signed-out device (Skip is there), but
deviceOnboardedsurvives sign-out and Delete Account, so its sign-in on step 4 skips the permission ask and the reveal. - The tutorial gives you nothing. No patch, no card, no pack. No step of
/onboarding-tutorialcollects, unlocks, or awards anything — not the intro patch, not the boundary crossing, not the collection page (nor, on v1.1.99 to v1.1.109, the pack and the four rarity cards). The camera-roll beat's example photos are not imported, and its patch art and ticks are an illustration. Nothing any of it shows is added to the user's album, count, collections, achievements or leaderboard total. 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 intro patch is a PREVIEW, and the camera-roll photos are EXAMPLES. Step 1 shows one real catalogue patch (Boston Common whenever the catalogue has it — the same patch for every user, chosen by a fixed rule, not a draw) to say what a patch is; the user does not receive it, and "This is Boston Common's." names the place, not an award. Its rarity line is Scout's authored ranking of how hard a place is to reach — never a drop rate (see patch-rarity). The camera-roll beat's photos are catalogue photos of real places, tagged "Example photos", not the user's camera roll, which the beat never reads and never asks permission for. There is no loot box, no purchase, no currency anywhere in onboarding. (v1.1.99 to v1.1.109's "Field Pack" and four rarity cards were the same kind of preview; the cards were labelled "Preview · not yours yet".)
- The tutorial's boundary is illustrative, not the user's real geofence. Step 2's fence
(
FENCE,starterPack.ts:194-196) is a fixed, invented shape on an invented map at an invented scale (FEET_PER_MAP_UNIT), drawn purely to teach the concept of containment — it is not derived from any real park/monument/city boundary and crossing it unlocks nothing. The pin at its centre wears a real patch's art (the intro patch, Boston Common when present), but the fence around it is not that place's boundary, and the "Inside — the patch unlocks" readout describes the illustration, not an unlock. 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 intro patch, the collection page and the camera-roll examples are not personalised by location. They are the same for every first-run user on every device, because steps 1-3 run before the location permission ask and there is no fix to be near yet. They are not "patches near you." The only personal thing on them is the collection page's count and "Next" slot, which reflect what the user has actually collected (0 for a new account).
- The collection page does not show progress the user does not have. It used to (the previous tutorial drew Boston at 4 of 11); it no longer does. A new user sees 0.
- There are no standalone permission screens any more.
/onboarding-locationand/onboarding-notificationswere deleted on 2026-09-20; the ask is the last step 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. - The permission step does not simulate the 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" and no Skip on this step: a user who does not want a permission declines it in the system dialog, and the flow carries on either way.
- There is no replay. Nothing in the app re-opens the tutorial — no "replay tutorial"
in Settings, the drawer or anywhere else. It returns only as the next step after a
first interactive sign-in on a never-onboarded device (see Status).
The
scout://onboarding-tutorialdeep link exists for development and testing; it is not a user-facing entry point. - The reveal's EMPTY state was not redesigned. When something unlocked, the reveal is
the Ember Surge stage (see Surfaces); when nothing did, it is still the previous
PatchUnlockRevealempty state ("You're not inside anything — yet.") onEmbersBackground. - A returning account never sees an empty reveal. Existing accounts go through the
tutorial too, and a patch never unlocks twice, so one standing where it has already
collected gets an empty pass. If the pass unlocked nothing and the account already owns
at least one patch, the reveal is skipped (Alan, 2026-10-02): it holds its skeleton
and runs the same exit as Next once — drain both queues, set
firstRunRevealSeen/tutorialSeen, clearonboardingPending— reported asonboarding_completed.reveal_state = 'already_collected'(mobile/app/onboarding-reveal.tsx:234-250,skipAsReturning), and lands on the camera-roll beat (since 2026-10-19; before, it went straight home with the rating ask). The skip applies only in the reveal phase, so it cannot fire on the camera-roll beat (see How it works, item 9). A NEW account (owns nothing) with nothing nearby still gets the honest empty state. Tests: "skips the reveal for a returning account that unlocked nothing new, onto the camera-roll beat" paired with "still shows the honest empty state to a NEW account…" inmobile/screen-tests/onboarding-reveal.test.tsx. - 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 replay on sign-in. Since 2026-10-24 a signed-out device that has never finished onboarding opens on the tutorial (that is the front door), but a restored session never owes it, and signing out and back in on a phone that finished it goes straight in (it used to show the whole flow again).
- 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 the flow behind a server feature flag or staged rollout — the
only flag touching it is
guest_mode, which hides one link on the account step; 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).
Analytics events
Since 2026-09-30 (tracking plan Change 2). Full table in
analytics-events.md. Signatures:
mobile/src/services/analytics.ts:512-565.
onboarding_step_viewed{ step: 1-5, step_name, via: 'mount'|'next'|'skip' }— an effect on the step SHOWN (shownIndex) inapp/onboarding-tutorial.tsx:180-192, so the permission step a sign-in reveals is logged once, withvia: 'next'; the tutorial is ONE route, soscreen_viewalone could never say which step people left on.step_nameis one ofintro,boundary,collections,account,permissions(STARTER_STEPS; signatureanalytics.ts:515-528). Renumbered 2026-10-24 (in store builds since v1.1.112): the account step is step 4 and permissions moved from 4 to 5, sostep4 meanspermissionsbefore this date andaccountafter it; compare bystep_name. The drop-off on the account step is read from this event (accountviewed,permissionsnot): there is no separate event for it. Renumbered 2026-10-19 (in store builds since v1.1.110): from 2026-10-02 to 2026-10-19, and on v1.1.99 to v1.1.109, the steps were 1-5,starter_pack,rarity,boundary,collections,permissions; before 2026-10-02 they were 1-4,what_scout_is,boundary,collections,permissions.stepnumbers are not comparable across either date (step 2 isboundarynow and wasrarityin the Starter Pack; the permission step is 5 now, was 4 from 2026-10-19 to 2026-10-24, and was 5 on the Starter Pack). Compare bystep_name, and expect a mix of schemes while older and newer builds are both in use.onboarding_skipped{ from_step: 1-3 }—handleSkip(onboarding-tutorial.tsx:348-352). It was 1-4 on the Starter Pack (2026-10-02 to 2026-10-19, and v1.1.99 to v1.1.109) and 1-3 before that; the step a number means differs between the schemes, as above.permission_result{ permission, context: 'onboarding', status, background, accuracy, already_answered }—driveLocation/driveNotifications; a request that throws reportsstatus: 'error'.onboarding_completed{ reveal_state: 'patches'|'empty'|'timeout'|'already_collected', patch_count, achievement_count, wait_ms }—handleContinueinapp/onboarding-reveal.tsx:170-206, fired once, on the reveal's Next or on the returning-account skip, before the camera-roll beat shows. (On v1.1.99 to v1.1.109 it fires on "Start exploring".) There is no new event for the beat's choice — the backend allowlists event names (backend/src/analytics/dto/track-event.dto.ts) — so a scan shows up as ascreen_viewof/import-modalright afteronboarding_completed, and "Maybe later" as its absence.
Tests that cover it
mobile/src/components/onboarding/__tests__/starterPack.test.ts— the pure model: the step order (intro → boundary → collection → account → permissions, permissions last,ACCOUNT_STEP_INDEXandPERMISSIONS_STEP_INDEX);resolveRetraceExamplesreturning Acadia, Boston Common and Boston in that order, filling a missing one from other places with art and a photo by name, and never using a place without a photo;resolveCollectionPagereturning the user's real count (zero for a new user, never an illustrative fill), marking the first uncollected member by name as next, capping atCOLLECTION_PAGE_MAX_SLOTS, and preferring Boston, else a 6-20 member collection, never inventing one; and the boundary geometry — the walk starts outside the fence and ends inside it, crosses strictly mid-walk (so there is a stretch where nothing fires), and the distance to the line closes to about zero at the crossing.mobile/screen-tests/onboarding-tutorial.test.tsx— the route, seeded with real catalogue rows (Boston Common and the Field Museum with their tiers and rarity notes, Boston's fifteen members). Intro: one real patch with its name, tier and own rarity note, and no pack or cards on screen; Account: the Field Museum on the account step (its S3 art and photo,memory-disk, its photo's blurhash) while step 1 keeps Boston Common, and the Field Museum still there with a cold catalogue; "How do I get one?" enabled, one tap to the boundary; with no catalogue at all step 1 still opens on Boston Common, its name, tier, note, its S3 art and photo withcachePolicy="memory-disk"and the photo's blurhash (the 2026-11-04 regression test, seen failing on the catalogue-resolved code); the intro is a scroll view (scrolls inside its own bounds rather than slide under the CTA). Boundary: "ft to the line" with a numeric feet readout, the card not yet lifted and the CTA disabled, paired with the crossed state ("Inside" / "the patch unlocks", the card lifted, CTA enabled). Collection: Boston at 0/15 for a new user with the first member by name lit as next, paired with its falsifier — the same seed plus one collected member reads 1/15 and points past it — and the cold catalogue holding the page shape with no name and no count. Permissions (carried over from the previous tutorials): the background disclosure is on screen and no permission has been requested yet (the ORDER, not merely the presence of the text), and is absent from the lesson steps; 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 disclosure to the camera-roll Import alternative; the notification copy promises only the unlock notification and never a proximity one; 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 the account step rather than past it, and the permission step has no Skip; the grant celebration fires once per yes and not on a decline; the whole step renders with motion ON. Account step (since 2026-10-24): "Skip lands on the account step, which shows its pitch and the entrances and no Skip" (onboarding-tutorial.test.tsx:322); the paired "the account step shows the patch from step 1" (:353, the seed's own art URL) and "a cold catalogue keeps the account step and its entrances, with no art" (:363); "Got it on the collection step leads to account, not permissions", "a sign-in on the account step advances to permissions in place"; "never commits an empty account step on the sign-in that clears it" (a ReactProfilersees every committed frame, so the one-frame blank account step the advance effect used to leave is caught, and the account and permission steps are each logged once); and the paireddescribe('a session that lands during the lesson')(:490), where Skip and Got it go straight to permissions and never mount the account step. Dots: seven beat dots with the fifth active on the permission step and the reveal and camera-roll beat still ahead. Analytics:(1, 'intro', 'mount')then(2, 'boundary', 'next'); a skip from step 1 loggingonboarding_skipped(1)and landing(4, 'account', 'skip'), the permission step withvia: 'next'once a sign-in clears the account step, and a signed-in relaunch logged as the permission step onmount; location asked with the onboarding context; a throwing notification ask.mobile/screen-tests/account-after-tutorial-flow.test.tsx— the account step on the REAL navigator (renderRouterfromexpo-router/testing-library, with the realAuthGate,OnboardingProvider, tutorial and/auth/email): fresh signed-out device → tutorial → Skip → account → "Continue with email" pushes/auth/email→ code sign-in → back on the SAME tutorial instance (one route in the stack, same key, mounted once) at the permission step. Fails ifOnboardingProviderreplaces or pushes the tutorial instead ofdismissTo(two tutorials in the stack), if it does not navigate (stuck on/auth/email), or if the tutorial stops advancing on sign-in. Its paired opposite-branch test (2026-10-25): an onboarded, signed-out device lands on the tutorial, not/auth, and its Google sign-in on step 4 goes straight home with no permission step; it fails with the tutorial's straight-home effect disabled (verified).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; the empty state offering a way forward rather than dead-ending; the reveal as beat 6 of 7 (five done behind it, the camera-roll beat still ahead; "carries the sixth beat of the flow's dot strip",onboarding-reveal.test.tsx:85); "reads Next, because the camera-roll beat follows", and "still reads Next with seven dots on a device that has already scanned" (:116);onboarding_completedfor an empty reveal and a two-patch reveal; "skips the reveal for a returning account that unlocked nothing new, onto the camera-roll beat" (the beat shown,router.replacenot called) paired with the new account's honest empty state. The camera-roll beat (describe('the camera-roll beat')): it follows the reveal with the labelled example photos, the no-access privacy line and the last dot on, with the reveal's bookkeeping already done (firstRunRevealSeen,tutorialSeen) and no navigation yet; the cloud-album privacy line for an account with access; "Scan my camera roll" replaces to/(drawer)and only then pushes/import-modal; "Maybe later" replaces to/(drawer)and pushes nothing; a new account that just collected is not skipped past the beat by the returning-account rule after the drain; "sends onboarding_completed on Next, before the choice" (reveal_state: 'patches',patch_count: 1); and "follows on a device that has already scanned, too". No rating ask (no rating ask after the first reveal): neither after "Maybe later" nor on the scan path.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/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-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 five tutorial steps: the intro (tutorial-step-intro) with its headline and patch name; the boundary status waited on (rather than slept for) until it reads "the patch unlocks"; the collection page with its headline and a real collection name; the account step (step 4), where it signs in with the throwaway account and the tutorial advances in place; and on step 5 both permission rows, the location ring still pending, and the background-location disclosure on screen with a neutral CTA (never "Turn on location") before either OS dialog is opened — then both real OS dialogs in order, and the unprompted hand-off to the reveal. Section 7 (since 2026-10-19) taps the reveal's "Next", waits foronboarding-retrace-screen, asserts "Fill in where you've already been." and the scan button, and takes "Maybe later". The shared helpermobile/maestro/lib/grant-permissions.yamlskips the LESSON (Skip lands on the account step, so a caller signs in first) and drives the permission step's two presses plus the reveal's Continue for every other flow in the suite, which only needs to reach home, and since 2026-10-19 takes "Maybe later" (onboarding-retrace-later) wheneveronboarding-retrace-screenis visible. The step is conditional only so a caller that arrives past the reveal is not stuck; every flow through the reveal reaches the beat. The testIDs it depends on (tutorial-skip,tutorial-permission-rows,tutorial-next,tutorial-perm-location-done) were kept through both rebuilds.
Open questions
- The exact wording/order a screen reader announces across the five tutorial steps and the
camera-roll beat was not verified against a real VoiceOver/TalkBack session. In code: each
lesson step's headline and body are ordinary text; the intro's patch stage is one
accessibility element whose label carries the name, the tier and the rarity note
(
IntroStep.tsx,a11y); the boundary readout is a polite live region (BoundaryStep.tsx:178); the permission step is one element whose label carries the disclosure (PermissionStep.tsx:53-56); and the camera-roll beat's example photos carry no accessibility labels of their own (each shows its place name as text). The rarity cards' per-card labels went with the cards on 2026-10-19. - The previous tutorial's modules (
TutorialMap.tsx,TutorialChallengeCard.tsx,WalkDistance.tsx,MARQUEE_PATCHES.ts,TUTORIAL_COLLECTION.ts,tutorialGeometry.ts) and their unit tests were deleted on 2026-10-02 with the Starter Pack rebuild; nothing in the app referenced them any more. The Starter Pack's own pack and rarity modules (PackStep.tsx,RarityStep.tsx,RarityCard.tsx,rarityCardFit.ts) were deleted on 2026-10-19 the same way.