Summary
Two mobile screens turn a user's collected patches into a printable-feeling
"field record": the 50 States map (a US outline shaded by how deep the
collection goes in each state) and the National Parks map (a dot per park,
with the two parks a standard US projection can't place boxed into a margin
inset). Both are display-only — no tap targets — and both export to a fixed
1080x1350 PNG via the same useShareCard hook for posting to Instagram/etc.
Separately, scout-patches.com is one NestJS origin (backend/) serving two
kinds of pages: a handful of server-rendered Handlebars pages for anything
that must never 500 or must be crawlable (terms, privacy, support, brand,
/context, public profiles), and a React SPA (landing/, built and copied
into backend/public/ at deploy time) for the marketing home page, /app,
and /discover/:patchId. A 2026-08-28 commit deleted the waitlist entirely —
every store badge across both projects now resolves to the live App
Store/Play listings, never a waitlist or beta signup.
Status
Shipped and live in production for both halves. No feature flag gates either
the 50 States/National Parks screens or any of the web surfaces described
here — they are unconditionally visible, including to guests (no
isVisible gate on their drawer rows, unlike messages/import;
mobile/src/components/navigation/drawerSections.ts:232-233).
New 50 States and National Parks screens are built behind album_places_redesign (defaultEnabled: false in both registries, mobile/src/config/feature-flags.ts:27 / backend/src/admin/feature-flag-definitions.ts:33; built 2026-10-28) — and the flag is ON in production (GET https://scout-patches.com/api/feature-flags returned true on 2026-10-29). With the flag on, scout://states-map and scout://national-parks-map are the Album's map with your collection in it (see "The 50 States on the album's map" and "The National Parks on the album's map" below), each sharing a "Photo map" card; with it off (a client that cannot reach the flag endpoint falls back to the registry default), the older screens exactly. Members on a current build therefore see the map versions today; re-check the live flag before saying otherwise.
User-facing surfaces (screens, routes, scout:// deep links, public URLs)
Mobile (drawer → PROGRESS section):
scout://states-map— the 50 States map (mobile/app/(drawer)/states-map.tsx, registered inmobile/src/dev/deepLinkRoutes.ts:51)scout://national-parks-map— the National Parks map (mobile/app/(drawer)/national-parks-map.tsx, registeredmobile/src/dev/deepLinkRoutes.ts:57)
Public web (all on scout-patches.com, one origin):
/— marketing home page. Gamified ondevelopsince 2026-10-30 — live once the backend + landing deploy that carriesc97a7603and later ships (re-checkscout-patches.com/before saying the live page has it) (landing/src/components/MarketingSections.tsx; designdocs/superpowers/specs/2026-10-30-landing-gamified-home-design.md, mocklanding/research/landing-gamified-lab.html): the hero is unchanged in copy, its art is the app's own City Challenge ring (landing/src/components/HeroRing.tsx, hidden under 860 px like the old art) and under the free line sits a live strip of community numbers — "patches collected · places unlocked · explorers" — fed byGET /api/landing-stats(below) and rendered only when all three arrive as positive integers (landing/src/components/HeroStats.tsx,lib/landing-stats.ts). Below the hero, in order: rarity tiers (four real patches, one per tier), "Light up the map" (the app's 50 state shapes lighting pale-to-rich), collection rings (six real collections, real sizes, in their ring-family colours), missions + the "Patch on us" punch card + a trophy case of achievement art, the real rope-snapback product photo, then the kept FAQ and close. Every number on it is either live (the strip) or an illustrative scout (landing/src/components/game/sample-scout.ts) labelled on the page with an "Example scout" chip in each section that draws it (the hero ring's caption, the map's counters, the rings' lede, the trophy case)./app— QR-hub "get the app" page./discover/:patchId— QR patch-discovery celebration page. All three: SPA./celebrate/:patchId— the patch's Ember Surge unlock celebration, and (at?render=1) the page the server records into the patch's share video. SPA. The app's Copy link and the text beside its shared video and image link it as?ref=<code>&src=<surface>: with a code, its store badges go to/i/<code>?src=…; without one they link the stores directly (ruling R-010), tagged with the surface (celebrateInstall,landing/src/lib/store-links.ts). See celebration-share-video and referral-and-invite.*(any unmatched path, incl. old/waitlistlinks) — falls through to the marketing home page (landing/src/App.tsx:16). SPA./brand— public-but-unlisted brand guide,noindex, nofollow. SSR./press— public press kit, indexable and in the sitemap. SSR. See press-kit./terms— Terms of Service / EULA./eula— 301 redirect to/terms. SSR./privacy— privacy policy for the mobile app and the scout-patches.com websites (App Store Connect / Play); since 2026-10-19 it has an "On our websites" section naming the visitor cookie, first-party analytics and the Meta Pixel./privacy/choices— opt this browser out of the Meta Pixel (cookiescout_privacy_optout); linked as "Your Privacy Choices" from every landing-page footer (landing/src/components/SiteFooter.tsx) and the shop footer. SSR./t.js— the first-party analytics tracker the public pages load (not a page). Internal doc: web-analytics./privacy-facebook— Meta/Instagram integration policy (Meta App Dashboard). SSR./support— support page (App Store Connect Support URL field). SSR./get— UA-sniffing smart link (App Store on iOS, Play on Android, else the site). SSR redirect, no HTML. Accepts?src=&c=&m=tags and, since 2026-10-13,ref=<invite code>, which goes into Play's install referrer only and is never logged./card— permanent business-card redirect (302), increments a scan counter. SSR redirect./card/qr.svg,/card/qr.png— QR codes encoding the permanent/cardURL. SSR binary/SVG./u/:slug— public profile "flex page"/credential./u/:slug/card.png— its 1200x630 OG image. SSR. The app shares it as?ref=<code>&src=profile; the install CTA forwards thesrc(defaultprofile) to/i/<owner's code>or/get./national-parks,/national-parks/:slug(+/card.png,/photo.webp) — the National Parks hub and one page per park, built for search. SSR./sitemap-index.xmland/sitemaps/places-<n>.xml— the sitemap index and its place pages. See place-pages./api/avatar/:seed.svg— deterministic generated avatar SVG. SSR binary endpoint./context,/context/upload— hidden, crawlable product-context page + its token-gated screenshot upload form. SSR./tiles/:z/:x/:y.mvt(+/tiles/meta) — self-hosted vector tile server the mobile map consumes. Not a page./.well-known/apple-app-site-association(+ the bare/apple-app-site-association) and/.well-known/assetlinks.json— the domain's half of iOS Universal Links and Android App Links (backend/src/app-links/). 200application/json, no redirect. They name the app (HYQH9C4873.com.saxal28.scout;com.saxal28.scoutwith the Play App Signing and upload-key SHA-256s) and claim only/authand/auth/*, plus/m/*when the backend runs withAPP_LINKS_CLAIM_MEMORIES=true(off by default;iosClaimedPaths; Android: the intent filterspathPrefix: /authandpathPattern: /m/.*, which ship in the app build — never a bare/mprefix, which would take/mapand/missions)./m/<code>is a 7-day shared memory, routed in the app bymobile/app/m/[code].tsx(on-this-day). iOS reads the AASA for every installed build, so the/m/*claim waits behind the env var until the build carrying the route is widely installed; claimed earlier, a tap would open older builds on their not-found screen. Not pages. This does NOT make/i/:code,/u/:slug,/celebrate/:idor/discover/:idopen the app: the app has no route for those paths yet, so claiming them would land on its not-found screen. Widening the claim ships with those app routes. Before 2026-10-13 neither file was served (both fell through the SPA catch-all as a 404), so no https link ever opened the app./api/celebrate/:patchId/card.png— the 1200×630 link-preview card a pasted/celebrate/:patchIdunfurls with (itsog:image). Not a page; see celebration-share-video./api/celebration-video/:patchId— a patch's share video:ready+ S3 URL, orrendering(poll). Not a page; see celebration-share-video./api/discovery-links/:id/click— fire-and-forget click beacon fired by/discover/:patchId./api/sync/patch/:id— single-patch lookup it uses (mention only). Neither is a page.
How it works
The 50 States map
The rule for when a state fills (counts toward "N/50") is exact and
distinct from the rule for its shading depth. A state fills — is added
to earned and counted in totals.states — only when the user has
collected that state's own collectionType: 'state' patch (e.g.
state-california); this is the only fill signal
(mobile/src/domain/statesProgress.ts:59-93). Any other collected patch
whose patch.state resolves into that state (a park, a city, a monument)
adds to the state's depth count and to totals.patches, but does
not fill the state on its own — and if the state patch itself was
never collected, the code deletes any partial depth accumulated for that
state (statesProgress.ts:85-93, comment: "Places in a state whose state
patch is NOT collected must not light it"). Depth maps onto a four-step
tier via fillTierFor (statesProgress.ts:108-116): passed (dimmest —
state patch collected, 0 other patches, "drove through, never stopped"),
shallow (1–4 other patches), deep (brightest, 5+), empty (unfilled).
The map screen prints a legend under the map with exactly these tiers:
"Passed through", "1–4 places", "5+ places", "Not yet"
(mobile/app/(drawer)/states-map.tsx:98-101).
The denominator is fixed at 50 (TOTAL_STATES), derived from the
US_STATE_SHAPES geometry, not the catalog — DC, territories, and Canadian
provinces present as state-type patches are counted by
useUserProgress's statesVisited but silently dropped here to keep the
denominator exactly 50 (statesProgress.ts:41-46). patches.state is free
text (has held both "Florida" and "FL"); every lookup runs through
canonicalStateName so the two forms don't split into two states
(statesProgress.ts:47-52).
Rendering (mobile/src/components/states/UsStatesMap.tsx) is plain
react-native-svg, not react-native-maps — tiles can't be restyled to
the field-guide palette or captured reliably for a share image
(UsStatesMap.tsx:9-11). 9 states too small to letter in place (VT, NH,
MA, RI, CT, NJ, DE, MD, plus Hawaii) get a right-margin index column
instead, and only lit states are lettered in place at all — labeling all
50 at phone width was judged to bury the map's signal
(mobile/src/components/states/mapLayout.ts:14-19,33-53).
The 50 States on the album's map (flag album_places_redesign)
Built 2026-10-28 (Alan: "the same map and functionality" as the Album's "Your America"). mobile/app/(drawer)/states-map.tsx renders StatesAtlasScreen when the flag is on and the old Survey Plate (StatesFieldRecordScreen, above) when it is off. The map, its three levels, glide, card and framing are the shared mobile/src/components/album/map/AtlasScreen.tsx (useAtlasViewModel + AtlasScreenLayout), which the Album's map uses too; this screen hands it a source built from the collection:
- What it holds:
buildCollectionAlbum(mobile/src/domain/statesMap.ts) turns your collected PLACE patches (not state or city containers,isPlacePatch) into the album's state → city → place tree, each place carrying the Album's photos of it, none when you never photographed it. A patch with no city/state or coordinates is not placed. "IL" and "Illinois" are one state (canonicalStateName). - Lit: by the collection rule only (
isLitStateoverbuildStatesProgress: the state's own state patch), never by photos, so the status line ("30 of 50 states · 167 patches", the old stubs' numbers) agrees with the rest of the app. A lit state you photographed is filled with your photo, as on the Album's map; a lit state you never photographed is a gold tint; an unlit state is a faint outline, even if you have photos there. - Going in: a tap goes into a state you lit or hold places in (a state patch can lag its places); anything else shows "Not yet". In a state, a bubble per city of the places you hold; at a city, the street map with their patch art.
- The card: a state reads "The 50 States", its places on the map ("3 places", "Passed through" for a lit state with none, "1 place · state patch not yet" for one you hold places in but not its patch) and its photos, with See album only when you have photos there (the state's album page, a full page). A place you photographed reads its photos and dates with See album; a place you hold but never photographed reads "Collected Jul 2021" with View patch (the patch's page). So a filled state never lands on an empty album page, the reason the old screen took no taps.
- Depth: a lit state with no photo wears the old plate's Passed through / 1–4 / 5+ shades (
colors.progress, at half strength,TIER_TINTinWorld.tsx), counting the places the map shows in it (fillTierForoverplacesIn), so the shade and the "3 places" line agree. The plate's legend sits along the bottom in those shades. - Chrome: the drawer's menu at the country (no Close), Share my map top right, and "Looking a little empty?" (
MapImportCallout) along the bottom on a near-empty map. - Share my map exports the Photo map card (
StatesPhotoCardon the sharedPhotoMapCardframe inmobile/src/components/field-record/, the camera-roll import's picked card): "My field record · The 50 States · 30 of 50 states · 167 patches", your lit states filled with the same files the map draws (shareLit) or the gold tint, your four rarest places (bestFinds: rarest, then most recently collected), States / Patches / Complete, and the invite-code footer. SameuseShareCardsurface (states_map_card) and share text as the old card, which the old screen keeps.
The National Parks on the album's map (flag album_places_redesign)
Built 2026-10-28 on the same shared map. mobile/app/(drawer)/national-parks-map.tsx renders ParksAtlasScreen when the flag is on and the old plate (ParksFieldRecordScreen) when it is off. Data: mobile/src/domain/parksMap.ts.
- Dots: every park the map can place (
parkDots; the two off the projection, American Samoa and Virgin Islands, are left out) is a dot at its pinned 960×600 position, gold with a dark ring when collected, a faint speck when not, drawn over the states at every level (dotsonAtlas). A tap within 16 pt of a dot is the dot's, before the state under it (nearestDot); every dot on screen has a named screen-reader target ("Zion, not yet"). - Tapping a park: one you hold goes into its state with its card; one you have not collected shows its "Not yet" card (its name, its state's outline) and the map stays where it is.
- Lit: a state holding a park you collected, filled with your photo of one where you have it. Going into a state shows each park you hold there as its own marker (
buildParksAlbumgroups by PARK, not by its gateway town). - The card: a state reads "National Parks", "2 of 3 parks" against the roster (
parksInState) and its photos, See album when photographed; a park reads its campaign line, and See album or "Collected Jun 2024" with View patch (collectionPlaceCard, shared with the 50 States). - Header: "National Parks", "12 of 64 parks · 6 of 11 regions"; the legend (Collected / Not yet) and "Looking a little empty?" along the bottom; the drawer's menu and Share.
- Share my map exports the Photo map card with the parks (
ParksPhotoCard): the lit states with your photos, every park a dot (gold when collected), your four rarest parks, Parks / Regions / Complete. Same surface (parks_map_card) and share text as the old card, which the old screen keeps. - Known limit: at the country scale the Utah and California parks sit a few points apart, so a finger can land on a neighbour; going into the state (bigger markers) or VoiceOver's per-park targets reach each one.
The National Parks map
Deliberately simpler: a park is one patch, so the fill rule is just "is
this park's patch collected" (mobile/src/domain/parksProgress.ts:6-11,52-58).
The 64-park roster (TOTAL_PARKS = 64) is generated and pinned into
mobile/src/domain/nationalParks.ts, not read from live collection
membership, so an unrelated CMS edit can never move the "x of 64"
denominator (nationalParks.ts:16-21) — regeneration is a documented
manual pipeline (psql → gen-national-parks.mjs using d3-geo/topojson
→ re-emit the file). Every park is projected with the same
d3.geoAlbersUsa fit (960x600, 6-unit inset) as the 50-states geometry,
so a park dot lands inside its own state polygon for free.
Two parks the Albers-USA projection cannot place at all — American
Samoa National Park and Virgin Islands National Park — get
x: null, y: null, offMap: true (that null return is the detection
mechanism, no hand-kept exception list,
mobile/src/domain/nationalParks.ts:22-25,173,176), drawn in a boxed
"OFF THE PLATE" inset in the map's lower-right margin
(mobile/src/components/parks/parksLayout.ts:23-29) rather than
distorting the projection — Hawaii's own parks (Haleakalā, Hawaiʻi
Volcanoes) render on-map normally in its standard lower-left inset.
Unearned dots paint before earned ones (SVG has no z-index; a lit dot
must not be buried under a close unlit neighbor, parksLayout.ts:38-48),
and a lit dot gets a halo ring because the tightest real pair (Carlsbad
Caverns/Guadalupe Mountains, 7.7 viewBox units apart) would otherwise
merge into one blob (parksLayout.ts:15-19). The screen's "roll" beneath
the map lists only regions/parks the user actually holds, with an
explicit empty-state copy block otherwise
(mobile/app/(drawer)/national-parks-map.tsx:132-164).
The "Looking a little empty?" import callout (both maps)
Users were opening these screens, seeing one lit state (or no parks at all)
and leaving. A one-state map is what a user who has only ever unlocked on foot
looks like: every trip photo from before Scout is still in the camera roll,
unclaimed. So both screens carry a callout directly under the map plate, above
the stubs, that opens /import-modal (the camera-roll import — see
camera-roll-import.md). Copy: "Looking a little empty?" over "Scan your
camera roll to fill in the states you've already been to" (50 States) or
"Scan your camera roll for the parks you've already visited" (National
Parks).
- Component:
MapImportCallout(mobile/src/components/field-record/MapImportCallout.tsx), which is home'sImportBandin itslead(brass-edged) skin with a per-screen copy override. It is the same object as home's band, not a lookalike. testIDsstates-map-import/national-parks-map-import. - Gate:
shouldPitchMapImport(mobile/src/domain/statesProgress.ts:137-146): one state or fewer (MAP_IMPORT_PITCH_MAX_STATES = 1) and no camera-roll scan has finished on this device (importScanCompletedAt, the same device-local marker that retires home's lead band, set onsuccessandno_matches). - Both screens count STATES, including the parks screen. Zero parks is a normal outcome for a well-travelled user, but one state is not, so a user with 2+ states and 0 parks sees no callout on the parks screen.
- It waits for data. While the content catalog or the user's collection
is still loading, the callout renders nothing
(
MapImportCallout.tsx:74). A cold start reads as 0 states until the collection arrives, which is exactly the callout's condition, so without this guard it would flash at a user with a full map. - Collage: the two tilted photo tiles are the user's own collected
patches'
locationImageUrls (the same source home's band uses). With none, the band still renders copy and the check badge.
The share/export pipeline (shared by both maps)
useShareCard (mobile/src/hooks/useShareCard.ts) captures an
off-screen view with react-native-view-shot's captureRef at a
fixed 1080x1350 (Instagram's 4:5 feed size) regardless of device
pixel ratio, shares via shareFile (mobile/src/lib/shareFile.ts: RN Share on iOS, expo-sharing on Android), then deletes the temp file;
isSharing prevents a debounce-less double-tap from firing two captures.
Each screen renders its visible plate/stats for interaction, and
separately renders an off-screen, fixed-width StatesShareCard/
ParksShareCard at the literal export size (left: -10000, never
paints but is still capturable) — both reuse the exact same
UsStatesMap/UsParksMap SVG component the live screen uses, so the
export can't drift from what the user sees. Both cards carry the Scout
wordmark, "FIELD RECORD" kicker, the same three stats, an "N of 50 states
so far" / "N of 64 parks so far" subline, and a footer. Since 2026-10-13 the
footer names the sharer's invite code — Scout · Invite code K7M2QX over
scout-patches.com/i/K7M2QX (InviteCodeLines, footer "2 · Name the code" in
mobile/research/invite-attribution-lab.html) — and a guest's card keeps
Scout · scout-patches.com/get. A first share mints the code
(useInviteCode().ensureCode, passed as useShareCard's prepare) and the
capture waits for the card to re-render with it; the text beside it is built
from the same answer (message receives it), so a slow mint never splits them. The text beside the card
(iOS only; expo-sharing has none) is "My 50 States so far on Scout:" / "My
National Parks so far on Scout:" and the sharer's /i/<code>?src=states-map /
parks-map link (/get?src=… for a guest). (The subline said "earned on foot"
until 2026-09-30; imports and drive-throughs fill these maps too, so it was not
true.) Capture failure is logged and swallowed
(useShareCard.ts:60-63) — the screen never crashes on a failed share.
The public web surfaces
The backend (backend/, NestJS) and the landing app (landing/, a
standalone Vite + React + react-router-dom project) are two separate npm
projects shipping to one origin. At deploy time
npm --prefix ../landing run build runs and its dist/ output is copied
wholesale into backend/public/ (.github/workflows/deploy.yml:183-185,213).
Nest's Express layer serves backend/public/ as static assets and falls
back to public/index.html for any GET not on an exclusion list — this is
what makes /, /app, and /discover/:id work as a client-routed SPA on
the same origin as the API (backend/src/main.ts:105-129). That list is
centralized and tested, not just remembered
(backend/src/spa-fallback.ts's servesSpaShell(), asserted exhaustively
by backend/api-tests/spa-fallback.api.spec.ts against every registered
GET route) — its comments document three real incidents it caught:
/tiles rendering the map empty, /terms//eula answering 200 with
marketing copy to App Store review, and public profiles//get//card
being swallowed by the SPA shell.
SSR pages (/terms, /privacy, /privacy-facebook, /support, /brand,
/context, /u/:slug, /national-parks and its park pages) are Nest controllers rendering Handlebars under
backend/views/, built to never 500: no auth, and either no DB call
(terms/privacy/support/brand — static copy) or one wrapped to degrade
gracefully. SPA pages (/, /app, /discover/:id) share one
index.html + bundled JS — it has one static <title>/description for
the whole app, no react-helmet or per-route Open Graph tag anywhere in
landing/src (confirmed by grep). This is why the split matters for
crawlers: a crawler or link preview that doesn't execute JS sees the
generic Scout tagline for /discover/:patchId too, never the specific
patch's name or art — the per-patch reveal exists only after client-side
JS fetches /api/patch/:id. The SSR pages, by contrast, render real
content directly in the HTML response (and for /u/:slug, per-profile
<title>/OG tags pointing at a real /u/:slug/card.png image).
- The road-trip stop planner (
trips-web/, served atscout-patches.com/trips): Deployed, not launched (live on the droplet since 2026-10-29, commit4c3cb766; nothing links to it and its flagsweb_saved_trips/web_markersare off, so it is not announced — see road-trip-planner.md).
Data model
Patch.collectionType— distinguishes a state-level patch ('state') forstatesProgress.ts, and classifies a patch as a "park" for profile stats viaPublicProfileService'sPARK_TYPESset (public-profile.service.ts:5).Patch.state— free-text state name/abbreviation; canonicalized on read for the 50 States map, never at write time.LandingConfig(singleton row) — admin-editable: video fields,appStoreUrl/playStoreUrl(public),iosBetaUrl/androidBetaUrl/inviteCopy(admin/app-only),cardRedirectTarget/cardScanCount(backs/card),businessCard(JSON, admin-only — excluded from the publicGET /api/landing-config) (backend/prisma/schema.prisma:1253-1283).LandingScreenshot— ordered rows for the screenshot carousel; defaults to 3 hardcoded S3 URLs if the table is empty.DiscoveryLinkClick— one row per/discover/:patchIdvisit:patchId, UTM fields,userAgent, a salted-hashipHash(never a raw IP) (backend/prisma/schema.prisma:1327-1342).Profile.slug— unique, permanent once assigned (ensureSlug,public-profile.service.ts:70-88); a guest's session-scoped display name is never used to seed it.Profile.publicEnabled—@default(true), public by default (opt-out; account-settings side out of scope here).
API surface
GET /api/landing-stats— public, no auth,Cache-Control: public, max-age=300(backend/src/landing/landing-stats.controller.ts). Returns{ accounts, patchesCollected, placesUnlocked, asOf }for the home page's strip (landing-stats.service.ts):accounts= profiles that are not internal and not anonymous (the old guest mode's rows have no email and never signed in; prod held 47 on 2026-10-30);patchesCollected= everyuser_patchesrow held by a non-internal user, GPS, import and manual alike (a guest's count: a real person went there);placesUnlocked= distinct patches among them. "Internal" is exactlyinternalProfileWhereinbackend/src/admin/admin.helpers.ts— admins, Maestro seeds,@example.com, the owner's plus-aliases, the legacy list, automated sources,ADMIN_EMAILS— passed through, never re-typed, so this, the analytics ingest and the admin Users list cannot drift. Counted at most once per five minutes per process; concurrent cold callers share one count. On 2026-10-30 prod read 239 / 7,234 / 1,277.GET /api/landing-config— public; feeds the SPA pages' store badges/video/screenshots (landing-config.controller.ts:65-88). Admin-guarded CRUD siblings exist for the same config + uploads (out of scope beyond noting it exists).GET /api/sync/patch/:id— unauthenticated single-patch lookup used by/discover/:patchIdfor a not-yet-synced or admin-only patch (backend/src/sync/sync.controller.ts:26-30).GET /api/discovery-links/:id/click— public 204 click beacon; no-ops silently on an unknown patch id or DB error.GET /get— 302 smart link viaresolveStoreUrl()(smart-link.ts:33-58) from the request User-Agent + the adminLandingConfigrow (read failure tolerated — falls back to the live listings, never 500s).?ref=carries an invite code into Play's referrer (withStoreAttribution).GET /card— 302 toresolveCardTarget(cardRedirectTarget)(defaults/app); incrementscardScanCountbest-effort (public-profile.controller.ts:78-92).GET /card/qr.svg/.png— QR codes always encoding the one permanentCARD_URL, never the resolved target, so a printed card never goes stale (card-link.ts:3-13).GET /api/avatar/:seed.svg— deterministic (FNV-1a hash ofseed) grid avatar.GET /u/:slug,/u/:slug/card.png— profile page and its 1200x630 OG image (composed withsharponbackend/src/common/link-card.ts, served byprofile-card.controller.ts).
Key files (annotated path:line list)
mobile/src/domain/statesProgress.ts:57-116—buildStatesProgress/fillTierFor.mobile/src/domain/nationalParks.ts:1-25— roster contract, off-map detection.mobile/src/domain/parksProgress.ts:52-78—buildParksProgress.mobile/src/components/states/{UsStatesMap.tsx,mapLayout.ts,StatesShareCard.tsx},mobile/src/components/parks/{UsParksMap.tsx,parksLayout.ts,ParksShareCard.tsx}— SVG rendering + layout + export cards for each map.mobile/app/(drawer)/states-map.tsx,national-parks-map.tsx— the two screens.mobile/src/hooks/useShareCard.ts— shared capture/share/cleanup.mobile/src/components/field-record/MapImportCallout.tsx— the "Looking a little empty?" import callout both screens render under the plate; gateshouldPitchMapImportatmobile/src/domain/statesProgress.ts:137-146. Mock mirror:mobile/research/components/map-import.js.backend/src/spa-fallback.ts,backend/src/main.ts:83-129— SPA-vs-controller routing boundary, static serving + fallback wiring.landing/scripts/prerender.mjs— build-time prerender of the static routes, wired intonpm run build.prerenderedShellForinbackend/src/spa-fallback.tsis the server-side half.landing/src/App.tsx— the SPA's route table;*rendersNotFoundPage.landing/src/pages/NotFoundPage.tsx— the 404 body.backend/src/spa-fallback.ts'sisLandingRoute— the server-side mirror that decides 200 vs 404.backend/src/legacy-redirects/—/waitlist→ 301/.landing/src/pages/MarketingPage.tsx,DiscoveryPage.tsx,landing/src/api.ts— the live home page and QR discovery page + its fetch/click-beacon client.landing/src/lib/store-links.ts,backend/src/public-profile/smart-link.ts— two independently-maintained store-URL resolvers, both with no waitlist/beta branch, and (since 2026-10-07) two hand-kept copies of the store-attribution tagging (withStoreAttribution: Playreferrer=, App Storept/ct) — see analytics-events.landing/src/lib/landing-config.ts(mergeLandingConfig) — the merge-not-replace fix behind the 2026-08-28 commit.backend/src/public-profile/*—smart-link.ts,card-link.ts,avatar.ts,avatar-embed.ts,og-card.ts(onbackend/src/common/link-card.ts),profile-card.controller.ts,slug.util.ts,public-profile.service.ts,public-profile.controller.ts.backend/src/context/{content.ts,context.service.ts,context.controller.ts}— the/contextproduct-brief page this doc feeds into; token-gated viaCONTEXT_UPLOAD_TOKEN.landing/public/robots.txt— a real static file, copied verbatim intodist/by Vite and then overbackend/public/, whereapp.useStaticAssetsserves it ahead of the SPA fallback. Before it existed, the path fell through the catch-all and answered 200 with the landing page's HTML.backend/src/context/sitemap.ts+sitemap.controller.ts—/sitemap.xml, GENERATED (the old staticlanding/public/sitemap.xmlis gone): the curatedSTATIC_ENTRIES(/,/app,/context,/terms,/privacy) plus one entry per published feature doc./brandis deliberately NOT listed — it sendsnoindex, nofollow(backend/views/brand/page.hbs:6), and listing it was a sitemap/robots contradiction (board 5fc1a8fc, fixed 2026-10-13).backend/api-tests/sitemap-route.api.spec.tsfetches every listed URL from the real app and fails on any non-200 or noindex response, reading the robots tags off the page itself rather than a hand-kept list..github/workflows/deploy.yml:162-213— where/howlanding/distbecomesbackend/public.
Configuration and flags
- No feature flag gates either mobile map screen or any public web route described here — everything ships unconditionally.
CONTEXT_UPLOAD_TOKEN(env) — gates/context/upload; missing disables the surface, a bad token 404s (not 401/403) so its existence isn't disclosed (context.service.ts:33-40).DISCOVERY_IP_SALT(env, hardcoded default if unset) — salts the click-beacon IP hash.LandingConfig.appStoreUrl/playStoreUrl— admin overrides for every store badge and/get//card; shipped default when unset is the live App Store/Play listings, hardcoded in bothlanding/src/constants.tsandsmart-link.ts(kept in sync by hand, not shared). No third "waitlist"/"beta" branch remains anywhere in this resolution chain.LandingConfig.cardRedirectTarget— admin-settable; defaults to/app.
Edge cases and known limits
-
The park roster is pinned, so a new park needs a regenerate. Kings Canyon National Park, the catalog's 63rd, was added to the campaign without one and was missing from the map ("x of 62") until the roster was regenerated on 2026-10-09 (
be73e98c). The fix shipped in v1.1.104; builds before it read "x of 62", and builds before the Chiricahua roster (added 2026-11-04, not yet released) read "x of 63". Chiricahua became the 64th park on 2026-10-20 and keeps its monument-era UUID (bd6a974f-…), so anyone holding the patch keeps it. The roster test (mobile/src/hooks/__tests__/parksProgress.test.ts) pins all 64 official parks, but nothing compares the roster to the live catalog automatically, so adding a park to the National Parks campaign still means re-running the generator (nationalParks.tsheader). -
Card shares are measured (2026-09-30):
useShareCard({ surface })tags the states and parks exportsstates_map_card/parks_map_cardviaanalytics.trackShare. The camera-roll import's "Share my map" card (ImportShareCard, on develop since 2026-10-28) uses the same pipeline asimport_result(camera-roll-import). Since 2026-10-07 iOS reportsshared/dismissedand the chosen app (shareFileuses RNShare); Android staysunknown(analytics-events.md). -
An unknown URL now answers 404, and
/waitlistanswers 301 (both since 2026-09-17). Previously every unknown path —/about,/shop,/store, outright nonsense — rendered the marketing home page under a 200: an unbounded set of URLs serving identical content, which is a duplicate-content signal and tells a crawler the site returns success for anything./shopand/storewere the sharpest edge, being exactly what somebody guessing the storefront would type.The fix has two halves that must agree.
isLandingRoute(backend/src/spa-fallback.ts) mirrors the client route table and sets a 404 status for anything not in it; the shell is still SENT, so the visitor gets a rendered page rather than a bare Express string.landing/src/App.tsxrendersNotFoundPagefor the same paths, so status and body agree.The two route tables are maintained separately —
landing/andbackend/are separate projects and cannot import from each other, the same arrangement asstore-links.ts/smart-link.ts. Add a route to one, add it to the other;landing/src/App.test.tsxcarries the reminder. A route added only to the client ships with a 404 status./waitlistis handled separately byLegacyRedirectsControlleras a 301 to/, because it is the most-referenced marketing path in the repo and 57 of the 81 people on the removed list were emailed links to it. -
/and/appare PRERENDERED at build time (since 2026-09-17).landing/scripts/prerender.mjsruns aftervite buildand renders each static route to real HTML withreact-dom/server+StaticRouter— the same machinerysrc/App.test.tsxalready uses, deliberately instead of adding Vike or vite-plugin-ssr. Each route gets its own<title>, description, canonical andog:tags, so/appis no longer indistinguishable from/in a search result.backend/src/spa-fallback.ts'sprerenderedShellForresolves a request to its built file; anything unknown falls back to the plain shell, including on a build that predates the script.redirect: falseon the public static mount is part of this. Prerendering writespublic/app/index.html, which makespublic/app/a directory, and serve-static answers a directory request with a 301 to the trailing-slash form — so/appstarted replying301 -> /app/. That is wrong twice:/appis an advertised URL, and the landing'scanonicalForstrips trailing slashes, so the canonical would have named a redirecting URL. With the redirect off the request falls through to the SPA fallback and is served at/appwith a 200.Before this the SPA served 41 characters of text — the
<title>and nothing else. Google renders JS on a second-wave crawl; most AI crawlers (ChatGPT, Perplexity) do not, so to them both primary marketing pages were blank documents. -
Prerendering did not make the pages substantial — they are hero-only.
/yields 373 characters and/app328. That is genuinely all the copy there is (MarketingPage.tsx:93, "hero only, no scroll"). The remaining thinness is a CONTENT gap, not a rendering one, and no amount of prerendering fixes it. -
/discover/:patchIdis deliberately NOT prerendered. It is a route pattern with one instance per patch; prerendering it means either thousands of near-duplicate pages — the thin-content trap just fixed on the storefront — or baking patch data into static HTML that goes stale on the next content publish. It is a QR-scan landing page, not a search surface, and keeps the SPA shell. -
OpenGraph, Twitter card, canonical and JSON-LD ship since 2026-09-17. Before that
landing/index.htmlset only<title>,description,viewportandtheme-color, so a link shared to Facebook, iMessage or Slack rendered as a bare URL with no image or description. The social tags and aMobileApplicationJSON-LD block are static — they describe the app, which is true for every route. The canonical is per-route viasrc/hooks/useCanonical.ts, because the static one inindex.htmlpoints at the origin root and would otherwise claim/appand/discover/:patchIdare duplicates of the home page.canonicalForalso drops query strings (campaign traffic arrives withutm_*andfbclid, each of which is a distinct URL to a crawler serving identical content), drops fragments, and normalises trailing slash and case. -
og:imageis a purpose-built 1200x630 card since 2026-09-17, served same-origin at/og-card.pngfromlanding/public/(the same static dir that shipsrobots.txtandsitemap.xml). It replaced/brand-assets/scout-wordmark-stacked-on-dark.png, which was 1467x1327 (1.11:1) — every previewer crops to 1.91:1, so the shared link was letterboxed or centre-cropped everywhere.backend/scripts/make-og-cards.pygenerates it and the storefront's from ONE layout, using the real Fraunces/Inter Tight files and theglobals.csspalette, so the two origins read as one brand. The generator RAISESCardOverflowrather than drawing a card whose copy collides with the domain line — a broken card is invisible until a customer sees it.src/lib/social-card.test.tsenforces the contract againstindex.htmlon disk:summary_large_imageimplies bothog:imageandtwitter:image, absolute, identical, and exactly 1200x630. -
Stale/dead marketing constants:
landing/src/constants.tsstill exportsCAMPAIGNS,FEATURED_PATCHES,JUMPSTART_MEMORIES,COMMUNITY_POSTS,MASTER_PATCH_ART(a Collections grid, campaigns section, mocked community board, photo-import section). None are imported or rendered anywhere inlanding/src(confirmed by grep) —MarketingPageis explicitly "hero only, no scroll" (MarketingPage.tsx:93). A reader ofconstants.tsalone would describe sections that don't exist on the live site. -
No per-route SPA metadata:
/discover/:patchIdhas no per-patch<title>/OG tags — a link preview (iMessage, Slack, Facebook) shows the generic Scout tagline, not the patch, unless the crawler executes JS (most don't). -
/u/:slug/card.pngis the field-guide link card since 2026-10-13 (board 68b3aef3). It used to be an SVG on a purple gradient (#1a1a2e→#2d1b4e) set in whatever serif/sans the server had. It is now composed on the shared link card (backend/src/common/link-card.ts) — the layout, palette and Fraunces / Inter Tight files of/og-card.pngand the celebrate card: kicker "SCOUT FIELD GUIDE" in brass, the display name as a Fraunces headline (2 lines, then an ellipsis), "N patches collected", a "2 states · 2 parks · 1 collection complete" line that leaves zeros out (and disappears when all are zero), and the member's photo or identicon as a brass-ringed medallion on the right. Served by its ownProfileCardController(backend/src/public-profile/profile-card.controller.ts), through the sameCardServeras the celebrate card (backend/src/common/card-server.ts):- Cached in process for 5 minutes, keyed on a cheap fingerprint
(
PublicProfileService.cardFingerprint: name, photo URL and the set of collected patch ids), so a hit skipsgetBySlug's join entirely. Catalogue edits (a patch's state, type or collection) reach the card when the entry expires. - Renders budgeted at 60 a minute per IP, charged only on a cache miss — a cached card is never a 429.
- One render per card at a time: concurrent misses for the same card
join the render already running (
common/in-flight.ts). - A degraded card is not cached: when the member has a photo but its
fetch fails, the identicon card is served with
Cache-Control: public, max-age=30and the next request tries the photo again. - A render failure 302s to
/og-card.pnginstead of a 500. - The photo is fetched alongside the text renders and resized once.
- The card and the page's
og:titleshare one count wording (patchesCollectedinog-card.ts): "1 patch collected". A blank or whitespace display name reads "Field Explorer" like a missing one (public-profile.service.ts:95) — Pango refuses to set empty text./a/:token/card.png(backend/src/album/album-og-card.ts) is still an SVG set in system fonts and has not been moved onto the shared card.
- Cached in process for 5 minutes, keyed on a cheap fingerprint
(
-
Neither map has tap targets, for two different reasons: the 50 States map's Album is photo-derived, so a filled state without a camera-roll import would open an empty page; a park dot is ~2.4pt wide at phone width, below the 44pt minimum touch target (
parksLayout.ts:10-13). -
/get/store badges can't warn about a config gap: a nullappStoreUrl/playStoreUrlsilently falls back to the live listing with no admin signal — this exact gap (NULL store URLs in prod while both apps were live) caused the bug the 2026-08-28 commit fixed; the fallback masks the gap rather than surfacing it. -
/card's scan counter is best-effort: an increment failure is swallowed and the redirect still happens, socardScanCountcan under-count. -
Share export failures are silent to the user:
useShareCardlogs and stopsisSharingbut shows no error toast on a failed capture or share sheet.
What this feature does NOT do
-
The 50 States and National Parks on the album's map (flag
album_places_redesign, on in production, readtrue2026-11-06) do not pan or zoom freely (the levels are the navigation), do not draw the two off-projection parks (American Samoa, Virgin Islands) as dots, and do not light a state from photos alone (the 50 States lights by the state patch; the parks map by a collected park). -
Neither map lets a user tap into a state or park to see the photos earned there — a deliberate exclusion, not a gap. (The import callout is the one tap target, and it sits outside the plate.)
-
The import callout does not know whether the user has ever imported. The app cannot tell (
UserPatch.sourceis dropped by the sync pull, seecamera-roll-import.md). It only knows "one state or fewer" and "a scan finished on this device". A user who imported on another phone, or before a reinstall, and still has one state will be asked again, and a user with two states who never imported is never asked here. -
The callout does not count anything ("12 photos from places you haven't claimed"). A count needs the media-library permission the callout exists to get someone past.
-
A tap on the callout is not tracked as its own analytics event. The import it opens records
photo_import_unlocked_patch>=1as usual, but nothing attributes that import to the map screens. -
Neither map nor the discovery/landing pages show a non-authenticated visitor's own progress on the web — maps are mobile-app-only; web surfaces are marketing/QR/profile pages, not a web dashboard. The gamified home page is no exception: its ring, lit map, punch card and trophy case are one illustrative scout (
sample-scout.ts), not the visitor's data and not anyone's; the page never reads a session. -
The home page's live strip is three counts, not a leaderboard and not per-user: it never names, ranks or links a person. Internal accounts (
internalProfileWhere) are never counted, and anonymous guest rows never count as explorers. The strip is never baked into the static HTML and never shows a zero or a stale figure: before the fetch, on any non-2xx, network error, unparsable body or zero, the hero renders without it. -
The home page does not mention Patch Club — its flag is off and
patch-club.mdforbids any public mention until launch;landing/src/lib/banned-claims.test.tsfails the build on the string. -
Does not include the Shopify storefront,
store/, or the account/settings side of the public-profile opt-out (other agents own those). -
/discover/:patchIddoes not redirect — it renders in place on the SPA; the old server-side redirect flow was replaced by a client-side beacon. -
The card QR never encodes the resolved redirect target, only the permanent
/cardURL — intentional (a printed card must not go stale), but the image never reflectscardRedirectTarget. -
Only a shared memory link will open the installed app, and not yet. The app-link files (
/.well-known/apple-app-site-association,/.well-known/assetlinks.json) claim/authand/auth/*;/m/*is claimed on Android from the next store build and on iOS only onceAPP_LINKS_CLAIM_MEMORIES=trueis set on the backend. Tapping/i/:code,/u/:slug,/celebrate/:idor/discover/:idopens the web page even when Scout is installed, because the app has no route for those paths. Do not describe invite or share links as "opening the app"; a/m/<code>memory link will, once the store build carrying the route is installed and the iOS claim is switched on.
Tests that cover it
-
Home page (2026-10-30):
backend/src/landing/landing-stats.service.spec.ts(the exclusion isinternalProfileWherepassed through, guests excluded fromaccounts, distinct places, the five-minute cache, one in-flight count for concurrent callers, zeros on an empty DB) andlanding-stats.controller.spec.ts(public, the route, the cache header).landing/src/lib/landing-stats.test.ts(three positive integers or null — zero, missing, string, NaN, fractional, negative all null),components/HeroStats.test.tsx(the three figures and labels; nothing on null; no strip in the prerender),components/HeroRing.test.tsx(the ring says whatSAMPLE_SCOUTsays; the hero ring and the rings row agree on St. Louis; no old hero art),hooks/useInView.test.ts,components/game/sections.test.tsx(each section's copy, the 50 paths unlit in static HTML and the sample depths when lit, real collection sizes, the seed's mission copy and no XP, exactly the sample punches, the hat and no Patch Club),components/MarketingSections.test.tsx(every heading in order, the retired sections gone, > 2,500 visible characters, no video, alt on every lazy image, the FAQ and its JSON-LD, no Patch Club),data/usStateShapes.generated.test.ts(the copied shapes equal the app's),lib/asset-budget.test.ts(every bundled WebP ≤ 200 KB) andlib/banned-claims.test.ts(now with apatch clubrule). -
The 50 States on the album's map:
mobile/screen-tests/states-map.test.tsx(the "50 States on the album map" block: header, menu and Share; the flag-off plate; lit by the state patch never by photos; the depth shades; the state card and See album; a never-photographed place's "Collected …" and View patch; a photographed place's See album; an unlit state you hold places in; "Not yet"; the Photo map card and its share; each paired),mobile/src/domain/__tests__/statesMap.test.ts(buildCollectionAlbumand its grouping,collectedLine,isLitState,stateLine,bestFinds). -
The National Parks on the album's map:
mobile/screen-tests/national-parks-map.test.tsx(the "National Parks on the album map" block: header; the flag-off plate; dots and their screen-reader names; a finger on a dot before its state; "Not yet"; View patch; See album; the state card against the roster; the Photo map card and its share),mobile/src/domain/__tests__/parksMap.test.ts. The shared map itself:mobile/screen-tests/album-map.test.tsx; the import's card on the shared frame:mobile/screen-tests/import-modal.test.tsx,screen-tests/components/import-lightup-claim.test.tsx.
Invite code on the cards (2026-10-13): screen-tests/states-map.test.tsx
and national-parks-map.test.tsx — the card prints Scout · Invite code K7M2QX and its /i/ URL for a scout with a code, and a guest's keeps
Scout · scout-patches.com/get with no code line (falsified by printing the
line for a null code); the states card is also captured only after a
first-share mint has been drawn; both share texts carry /i/<code>?src=…
(or /get?src=…). landing/src/pages/CelebratePage.test.tsx pins /celebrate's
badges; backend/src/public-profile/public-profile.page.spec.ts pins /u/:slug's
CTA src.
Mobile — domain logic (pure, no renderer): statesProgress.test.ts
(fills-from-state-patch-only, depth excludes the state patch itself,
passed-through is depth-0-not-absent, DC/territory/province dropping,
canonicalization, fillTierFor's four steps, a cross-check that
totals.states agrees with useUserProgress's statesVisited);
parksProgress.test.ts (roster invariants — exactly TOTAL_PARKS, unique
ids, on/off-map positions, region counts — plus dedup/region/rounding);
mapLayout.test.ts/parksLayout.test.ts (paint order, inset placement,
halo sizing); usStateShapes.test.ts (the geometry itself). All under
mobile/src/hooks/__tests__/ or the relevant component's __tests__/.
Mobile — screen integration (real RN render, real SVG, no MapLibre
stub): mobile/screen-tests/states-map.test.tsx seeds one collected
state patch and asserts 1/50/2% vs. 0/50/0% with nothing collected
(paired falsification); national-parks-map.test.tsx does the same for
parks (1/64/1/11, roll shows "ALASKA"/"Denali" vs. the empty-roll copy).
The import callout is covered on both screens: it shows at one state (and at
zero) with the screen's own copy and pushes /import-modal on tap; it is
absent at two states (the paired falsification, since only the count
differs); absent once importScanCompletedAt is set; and on the states
screen it stays hidden while a delayed GET /api/sync/user-data is still in
flight, appearing only once the real one-state answer lands. The parks cases
all hold zero parks and vary only the state count, which pins that screen
to the states gate. Each guard was confirmed to fail its own test when
reverted. statesProgress.test.ts → shouldPitchMapImport pins the 1/2
boundary and the scan suppression.
mobile/research/tests/map-import-callout.test.mjs pins the same gate,
placement (after the plate, before the stubs) and copy against the HTML
mock.
Mobile — Maestro E2E: mobile/maestro/tests/share-maps.yaml, one flow
for both screens, documented as exercising only the empty/near-empty
state, which is also the state that shows the import callout, so it
asserts states-map-import / national-parks-map-import on both; globs the collected-count assertion (\d+ of 50) rather than a
literal, which broke once already. Explicitly does not cover fill
tiers, the color ramp, lit dots, halos, region grouping, or the exported
card image.
Backend: smart-link.spec.ts (resolveStoreUrl, incl. "never
resolves a phone to a waitlist or a beta"); card-link.spec.ts/
.controller.spec.ts (redirect resolution, scan-count tolerance, QR
generation); og-card.spec.ts (what the card says incl. plurals and zero stats; the PNG the route sends — 1200x630, field-guide background, brass kicker, medallion; a name full of markup characters; the photo path, fetched once; the 302 fallback; the cache, and a hit that skips getBySlug; a card whose photo fetch failed is not cached); public-profile.service.spec.ts (cardFingerprint moves only with name, photo or patches; null when the page is); public-profile.page.spec.ts (og:title says "1 patch collected"); backend/api-tests/profile-card.api.spec.ts (a real 1200x630 PNG over HTTP, a blank display name still draws the member card, 404 for an unknown slug); backend/api-tests/sitemap-route.api.spec.ts (every sitemap URL is a 200 and indexable);
public-profile.page.spec.ts (404 on unknown/opted-out slug, serial
derivation, visible-only catalog total, 12-tile overflow math, an
explicit "never leaks an email, coordinates, or a home town" test);
context.controller.spec.ts/content.spec.ts; landing-config.controller.spec.ts
(public defaults, businessCard admin-only exclusion);
backend/api-tests/spa-fallback.api.spec.ts (every registered GET route
correctly excluded from the SPA catch-all).
Landing (separate Vite/Node test runner): store-links.test.ts
("the bug this file exists to prevent coming back": an unconfigured badge
still lands on a real listing, never a waitlist); DiscoveryPage.test.tsx
(install actions render/link correctly even with no store URLs
configured, no shop CTA); landing-config.test.ts
(mergeLandingConfig's merge-not-replace fix).
Open questions
- Whether
landing/src/constants.ts's unusedCAMPAIGNS/FEATURED_PATCHES/JUMPSTART_MEMORIES/COMMUNITY_POSTSexports are dead code slated for removal or a section planned to return — intent not determinable from code. - Exact production traffic of
/context(unlinked, direct-URL only) and whether the map screens'opened_states_map/opened_national_parks_mapevents feed any admin dashboard — neither verifiable without analytics access, out of scope for this read-only pass.