feat(landing): app-store funnel — QR hand-off through /app, S-full hero, Get-the-app fold, footer block, /shhhhh hand-off, scroll-jack removal (TASK-21788) - #3018
Conversation
…app-link coverage (TASK-21788)
…the-app fold, footer block (TASK-21788)
…m' into feat/pwa-sunset-landing-funnel
…to feat/pwa-sunset-landing-funnel # Conflicts: # src/components/LandingPage/LandingPageClient.tsx # src/constants/migration.consts.ts
…into feat/pwa-sunset-landing-funnel # Conflicts: # src/components/LandingPage/LandingPageClient.tsx # src/components/LandingPage/hero.tsx # src/components/LandingPage/sendInSeconds.tsx
…, dedupe QR/store-pair components (TASK-21788)
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Code-analysis diffPainscore total: 7252.27 → 7290.29 (+38.02) 🆕 New findings (97)
…and 77 more. ✅ Resolved (81)
…and 61 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
🖼 Visual diff — 8 screens moved10 of 68 shots changed · 58 identical · baseline
job summary · before/after/diff images — artifact Fixture screenshots, no backend. Advisory — this check never blocks a merge. Posted from the default branch by ds-shots-comment.yml; the report it renders is untrusted data. |
There was a problem hiding this comment.
Chip review — no blocking findings — this is not an approval
P1 is fixed, but the Other store CTA still returns users to their detected store and the exact-head design-system ratchet still fails.
Findings
-
MAJOR · src/components/LandingPage/PhoneAppCta.tsx:63 · Route Other store to the alternate listing
On iOS the primary CTA already targets the App Store, but this link opens bare/app; SmartStoreRedirect detects the same iOS device and immediately redirects to the App Store again. Android mirrors the same failure with Google Play, so the advertised alternate store is unreachable. Build this link from the opposite StoreKind and pair its explicit store href with the matching click/handoff handler. -
MINOR · src/components/Migration/StorePair.tsx:85 · Restore the design-system ratchet
The exact-head ds-lint check still fails: legacyColorClasses 0→1, offScaleSpacing 188→190, offScaleRadius 37→39, and hoverNoActiveFiles 44→46. This footer branch contributes the legacyborder-n-1and hover-only styling; the added PhoneAppCta, HeroAppLockup, CountriesSignUpCta, and GetTheAppFold classes account for the remaining off-scale/hover regressions. Replace them with semantic color and allowed spacing/radius tokens plus pressed states, or record an explicitly reviewed baseline increase if the deviations are intentional. -
MAJOR · src/components/LandingPage/SEOFooter.tsx:74 · [claude-opus] Crawlable App Store link is gated on a placeholder date, not on the listing being live
SEOFooter.tsx:74-80addsPeanut on the App Store→STORE_URL.iosandPeanut on Google Playas plain anchors, gated only onDate.now() >= MIGRATION_CUTOVER_DATE. This footer renders on every marketing page in all four locales (app/[locale]/(marketing)/layout.tsx,/careers,/m/[slug],/shhhhh), and the gate is deliberately independent ofpwa-sunset, so the links appear for flag-off visitors too.
That makes it the only place in the repo where the iOS store URL becomes user- and crawler-visible without either of the two signals that encode whether the listing exists. migration.consts.ts:55 states the opposite: IOS_APP_STORE_LISTING_LIVE = false, with the comment "The iOS listing (App Store Connect app 6786373552) is not published yet: the store URL 404s" — which is exactly why Profile/index.tsx:47 suppresses the iOS store-update prompt. And MIGRATION_CUTOVER_DATE is itself marked "placeholder — set the real date before flag-on" (migration.consts.ts:13-15).
Failure scenario: the team does the documented next step and sets the real cutover date to a value at or before the next deploy, before the App Store listing is published (or before the flag is flipped). The next build ships a site-wide, crawlable link to a 404 App Store page in every locale's footer, and Google indexes "Peanut on the App Store" — while /home/chip/mono/product/app.md still declares format: pwa and "PWA means no app store approval delays", i.e. no app-store distribution at all.
The code is the wrong side here: the promise should follow the signals that already exist rather than a placeholder date. Fix: filter STORE_LISTINGS on IOS_APP_STORE_LISTING_LIVE for the iOS entry (Google Play is live and can ship on the date gate as-is), so the crawlable claim can never outrun the listing. product/app.md's format: and the "no app store approval delays" note then need updating as part of the actual launch, not as part of this PR.
Checked clean
- P1 is fixed: SendInSeconds now receives subtext from server-owned LandingPageContent and no longer imports the filesystem-backed landing loader.
- The smart-link handoff strips the analytics surface before forwarding payloads and sanitizes native destinations before routing.
- The iOS AASA, Android intent filter, and native export route set agree on the new /app exact and wildcard paths.
- The landing and /shhhhh migration paths remain flag-gated, and the /shhhhh handoff waits for settled authentication before redirecting a guest.
- Exact-head format, eslint, typecheck, native-export, unit, analyze, and deploy-preview checks passed; ds-lint and its aggregate ci-success check failed, while advisory ds-shots was still in progress.
- Focused local Jest execution was unavailable because this detached worktree has no node_modules; exact-head unit CI passed instead.
Security review: did not run — openrouter-http-402. This review is one reviewer short.
Third opinion by claude-opus: 1 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.
Exact head: 1aff3601ae72 · Context: repo, product · Took 17m
| </Button> | ||
| </a> | ||
| {showOtherStore && ( | ||
| <Link prefetch={false} href="/app" className="block text-center text-body-s text-n-1 underline"> |
There was a problem hiding this comment.
MAJOR: Route Other store to the alternate listing
On iOS the primary CTA already targets the App Store, but this link opens bare /app; SmartStoreRedirect detects the same iOS device and immediately redirects to the App Store again. Android mirrors the same failure with Google Play, so the advertised alternate store is unreachable. Build this link from the opposite StoreKind and pair its explicit store href with the matching click/handoff handler.
| isHero | ||
| ? 'w-full bg-white px-6 py-3 text-button-m hover:bg-white/90 md:py-7 md:text-button-l' | ||
| : isFooter | ||
| ? 'w-full border-n-1 bg-white px-6 text-button-m shadow-none hover:bg-white/90 hover:shadow-none md:text-button-l' |
There was a problem hiding this comment.
MINOR: Restore the design-system ratchet
The exact-head ds-lint check still fails: legacyColorClasses 0→1, offScaleSpacing 188→190, offScaleRadius 37→39, and hoverNoActiveFiles 44→46. This footer branch contributes the legacy border-n-1 and hover-only styling; the added PhoneAppCta, HeroAppLockup, CountriesSignUpCta, and GetTheAppFold classes account for the remaining off-scale/hover regressions. Replace them with semantic color and allowed spacing/radius tokens plus pressed states, or record an explicitly reviewed baseline increase if the deviations are intentional.
Runtime QA (single branch)Head verified
The one thing to fix2d. Flag on, the hero grows 878 → 936px at 1440×900 and pushes the marquee 36px out of the Not tested
|
Hero fold fix (flag on)Runtime QA caught a real regression on this branch: with the flag ON the marquee dropped below the fold at 1440x900, which the branch's own gate in Change (
The mascot keeps the same +5rem offset over the artwork cap it has today. Arithmetic. The hero is Marquee top, px. All four locales (
Mascot height, px. 152px at 1366x768 is the smallest it gets, above the 150px floor set for this change.
How verified. Spec. No change needed — both target viewports now pass Flag-off impact. None. Only the two flag-on class strings changed; flag-off measures 878 and 814, the same as before. |
…e (ds-lint ratchet) (TASK-21788)
|
Every other metric was already flat and stayed flat. Files touched (all already in this PR's diff):
DivergenceThe phone outline in fold 10 was specified at a 28px corner radius. The DS radius scale is Nothing else moves visually: the border colour is byte-identical, the padding/gap changes are 4–8px on a full-width button and one desktop row gap, and the Verified: |
There was a problem hiding this comment.
Chip review — no blocking findings — this is not an approval
P2 and P4 are fixed at this head, but the alternate-store CTA still loops to the detected store and the crawlable iOS listing is still controlled by the placeholder cutover date rather than listing availability.
Findings
-
MAJOR · src/components/LandingPage/PhoneAppCta.tsx:63 · Route Other store to the alternate listing
On iOS the primary CTA resolves to the App Store, but Other store still opens/app; a bare/appdetects iOS and immediately redirects to the App Store again. Android has the mirror failure, so the only alternate-store affordance loops back to the same listing. Compute the opposite store here and use itsstoreAnchorHrefplus matchingonStoreAnchorClickso the alternate listing and handoff tracking are explicit. -
MAJOR · src/components/LandingPage/SEOFooter.tsx:80 · Gate the crawlable iOS link on listing availability
The repository still marks the iOS listing unavailable, while this build-time gate publishes both store links solely when the placeholder cutover date passes. A deploy after that date can therefore emit a crawlable App Store link that still 404s; an earlier listing launch would remain uncrawlable until the date. Gate each listing on its actual release state (and redeploy when that state changes) instead of using the shared cutover calendar as a proxy. -
MAJOR · src/components/LandingPage/SEOFooter.tsx:74 · [claude-opus] SEO footer publishes an App Store link the repo itself says is not live
SEOFooter.tsxadds two crawlable store anchors gated onDate.now() >= MIGRATION_CUTOVER_DATE. Two problems with that gate, both product-truth:
-
src/constants/migration.consts.ts:55statesIOS_APP_STORE_LISTING_LIVE = falsewith the comment "The iOS listing (App Store Connect app 6786373552) is not published yet: the store URL 404s".STORE_URL.iosis exactly the href this block emits asPeanut on the App Store. The rest of the codebase respects that constant (src/components/Profile/index.tsx:47suppresses the iOS store-update prompt on it); this new block does not. A crawler following it gets a 404, and the link is in the site's link graph on every marketing page, in all four locales, for flag-off visitors too — the file's own doc comment calls that out as accepted divergence 12. -
The date it is gated on is a placeholder.
MIGRATION_CUTOVER_DATE = new Date('2026-12-31T00:00:00Z')carries the comment "placeholder — set the real date before flag-on". So the condition that publishes a public, indexable promise that Peanut is downloadable from both stores is a value nobody has committed to, and it is read at build time, so it flips on whichever redeploy happens to land after it.
The product side agrees with the constant, not the code: /home/chip/mono/product/app.md (last_updated 2026-07-05) still records format: pwa and "PWA means no app store approval delays" — there is no documented store availability for Peanut at all.
Fix: gate the iOS entry on IOS_APP_STORE_LISTING_LIVE (and ideally both entries on listing-live constants) rather than on the placeholder date, so the crawlable link appears when the listing exists rather than when a build happens to run late enough. product/app.md also needs the native-app facts before the flag goes on, but that is a docs follow-up, not this PR's blocker.
Checked clean
- Confirmed the detached HEAD and merge base exactly match the supplied head and base SHAs; git diff --check is clean.
- P2 is fixed: StorePair now uses the design-system border token, valid spacing, and pressed-state treatment.
- P4 is fixed: sendInSeconds no longer imports filesystem-backed landing content, and the server component passes the reused subtext into it.
- Traced deferred payload creation, smart-link store routing, native /app mapping, and the landing/shhhhh handoff paths.
- Exact-head analyze and review checks are successful; the focused local Jest command could not start because this detached worktree has no node_modules/Jest binary.
Security review: did not run — this change has no security, privacy or money surface, so it was not asked. This review is one reviewer short.
Third opinion by claude-opus: 1 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.
Exact head: 1a4bf7496d4f · Context: repo · Took 15m
| </Button> | ||
| </a> | ||
| {showOtherStore && ( | ||
| <Link prefetch={false} href="/app" className="block text-center text-body-s text-n-1 underline"> |
There was a problem hiding this comment.
MAJOR: Route Other store to the alternate listing
On iOS the primary CTA resolves to the App Store, but Other store still opens /app; a bare /app detects iOS and immediately redirects to the App Store again. Android has the mirror failure, so the only alternate-store affordance loops back to the same listing. Compute the opposite store here and use its storeAnchorHref plus matching onStoreAnchorClick so the alternate listing and handoff tracking are explicit.
| ] | ||
|
|
||
| /** Evaluated once per prerender — see the note above: build time, not request time. */ | ||
| const storeListings = () => (Date.now() >= MIGRATION_CUTOVER_DATE.getTime() ? STORE_LISTINGS : []) |
There was a problem hiding this comment.
MAJOR: Gate the crawlable iOS link on listing availability
The repository still marks the iOS listing unavailable, while this build-time gate publishes both store links solely when the placeholder cutover date passes. A deploy after that date can therefore emit a crawlable App Store link that still 404s; an earlier listing launch would remain uncrawlable until the date. Gate each listing on its actual release state (and redeploy when that state changes) instead of using the shared cutover calendar as a proxy.
ds-shots follow-up (head 2add057)Fold gate at 1366×768. The four The spec now splits the two states at that height:
1440×900 keeps the strict assertion in both states (flag off 878 < 900, flag on 872). Result against the preview ( dev-showcase timeout — pre-existing, not caused by this PR. It was reported as
What did change is the load beside it: the flows suite went from 10 tests to 43 on 2 workers, so the sweep gets less CPU and crossed a line it was already touching. Raising that test's timeout (or splitting the sweep) belongs in its own PR against the spec, so this branch leaves it alone. |
Vercel preview smoke (production build)Target:
Notes (non-blocking)
|
There was a problem hiding this comment.
Chip review — no blocking findings — this is not an approval
Two previously raised defects remain: the Other store link returns Android visitors to Google Play, and a post-cutover build can publish the unavailable iOS listing. The design-system finding is fixed, and exact-head required CI is green.
Findings
-
MAJOR · src/components/LandingPage/PhoneAppCta.tsx:63 · Route Other store to the alternate listing
This remains reproducible (and is the same defect recorded as P1). On an Android phone the primary CTA already targets Google Play, but Other store links to/app; that route detects Android and redirects to Google Play again, so the advertised alternate store is unreachable. Compute the opposite store here and use its real store href and click handler instead of routing through the device-detecting smart link. -
MAJOR · src/components/LandingPage/SEOFooter.tsx:80 · Gate the App Store link on listing availability
This remains present (the same defect recorded as P4 and P5). A build at or after the cutover returns both listings unconditionally, whileIOS_APP_STORE_LISTING_LIVEis still false and documents that the iOS URL is unavailable. Redeploying this same commit after the date therefore publishes a crawlable 404 across marketing footers. Filter the iOS entry on the availability constant, or make the cutover gate require it, and cover the unavailable post-cutover case. -
MAJOR · src/app/app/page.tsx:96 · [moonshotai/kimi-k3] Attacker-controlled dest param drives in-app routing on /app (open redirect risk)
src/app/app/page.tsx reads window.location.search, and when the payload marker is present calls applyDeferredPayload(parsed).dest and passes it straight to router.replace(dest ?? '/home'). Anyone can craft peanut.me/app?pnutdl=1&dest= and get a victim to open it — the Android manifest and apple-app-site-association now claim /app and /app/, so the link opens directly inside the native app where router.replace executes with no visible validation in this file. If parseDeferredPayload/applyDeferredPayload (in src/utils/deferred-link.ts, not fully visible in this diff) do not strictly allowlist internal app routes, a dest of the form //evil.com or an absolute URL would let an attacker bounce a victim from a trusted peanut.me link to a phishing site that appears to come from the app, and a dest pointing at privileged in-app routes could pair with the invite/campaign auto-apply. Fix: validate dest against a strict allowlist (e.g. ^/[a-z0-9-/]$ plus a known-destinations set) inside parseDeferredPayload and fall back to /home on anything else; add a test that a hostile dest is rejected.
Checked clean
- Confirmed the detached worktree head, merge base, trusted author, and dev target exactly match the supplied values.
- P2 is fixed: the footer store button now uses the design-system border token and interaction-state classes, and exact-head ds-lint passes.
- P1 duplicates the still-present P3 Other store defect; P4 and P5 duplicate the still-present P6 listing-availability defect, so each cluster is reported once.
- Traced QR payload construction, Android install-referrer delivery, iOS clipboard delivery, native /app application, App Links route mapping, landing CTA interception, and /shhhhh handoff without retaining another defect.
- Exact-head ci-success, unit, typecheck, native-export, eslint, format, and ds-lint completed successfully; advisory ds-shots was still in progress.
- A local focused Jest run could not start because the detached worktree has no node_modules/Jest binary; no dependencies were installed into the read-only review worktree.
Security review by moonshotai/kimi-k3: 1 finding(s), marked with the model name. It reads the diff only and answers only security, privacy and money, so treat its findings as advice.
Third opinion by claude-opus: 0 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.
Exact head: 2add0577c3f7 · Context: repo · Took 17m
| </Button> | ||
| </a> | ||
| {showOtherStore && ( | ||
| <Link prefetch={false} href="/app" className="block text-center text-body-s text-n-1 underline"> |
There was a problem hiding this comment.
MAJOR: Route Other store to the alternate listing
This remains reproducible (and is the same defect recorded as P1). On an Android phone the primary CTA already targets Google Play, but Other store links to /app; that route detects Android and redirects to Google Play again, so the advertised alternate store is unreachable. Compute the opposite store here and use its real store href and click handler instead of routing through the device-detecting smart link.
| ] | ||
|
|
||
| /** Evaluated once per prerender — see the note above: build time, not request time. */ | ||
| const storeListings = () => (Date.now() >= MIGRATION_CUTOVER_DATE.getTime() ? STORE_LISTINGS : []) |
There was a problem hiding this comment.
MAJOR: Gate the App Store link on listing availability
This remains present (the same defect recorded as P4 and P5). A build at or after the cutover returns both listings unconditionally, while IOS_APP_STORE_LISTING_LIVE is still false and documents that the iOS URL is unavailable. Redeploying this same commit after the date therefore publishes a crawlable 404 across marketing footers. Filter the iOS entry on the availability constant, or make the cutover gate require it, and cover the unavailable post-cutover case.
|
Superseded by #3027, which carries the selected additions from this PR while retaining #3024’s single Download now action and shared desktop QR modal. Included: remaining landing CTA routing, mobile store fallbacks, plain footer store links, #3027 is verified and ready for human review. Real-device native validation and the launch-content update remain required before enabling the migration flag. |
Summary
The whole peanut.me → app-store funnel for TASK-21788, as one PR. Behind the PostHog
pwa-sunsetflag: every web CTA on the landing page, the /shhhhh door and the sticky bar stops sending people into a signup that is closing, and starts handing them to the app — a QR on desktop, the right store deep link on a phone, with the deferred context (locale, invite code, queued badge campaign, destination) surviving the install on both platforms.This supersedes the four branches it was built from. They were reviewed separately, merged here in order (mechanism → page → scroll-jack → shhhhh), and the two components the page branch had to duplicate — because the mechanism branch's API was not on
devyet — are collapsed into one implementation each.Flag off, the page is what ships today.
Screenshots
Captured from the Vercel preview of head
2add057(production build) at deviceScaleFactor 1, animations off. "Before" is the same build with the flag off — byte-identical to what ships today; "after" ispwa-sunseton.The SIGN UP button becomes the S-full lockup (QR + scan hint + store pair, subtext kept); the phone gets one full-width DOWNLOAD NOW with the detected store's mark and an "Other store" link.
The whole page, flag on — hero, folds, get-the-app fold, footer (1440 wide, full page):
The 1366×768 laptop fold — the re-tuned artwork clamps keep the marquee inside the fold:
The hoisted QR modal, opened from the fold-4 rates "Send Money" CTA (
dest=/send):Fold 10, now the get-the-app fold — scanner capture in the phone outline, 224px QR, stacked store pair:
The new footer block (
FooterGetTheApp) aboveSEOFooter:The whole phone page, flag on (390 wide, full page):
The sticky Download bar, scrolled 500px on a phone:
Where a scan lands —
/app?pnutdl=1&lang=pt-br&invite=ABC123&dest=%2Fsend&s=landing_heroon an iPhone (Android auto-redirects through the Play referrer instead):The /shhhhh door, flag on — "TRY THE DOOR" hands off with
dest=/cardinstead of routing to /setup:The pt-BR hero — the lockup copy resolves against the URL locale, not the device:
What changed
Mechanism — the hand-off itself
DownloadQRtakes apayload(a querystring the caller holds) or ahandoff({dest, invite}) it derives one from after mount, never both, so the code and the store buttons under it cannot carry different context. Encodes${origin}/app?<payload>&s=<surface>;sizepicks the frame (160 / 192 / 224px);MIGRATION_QR_SHOWNfires once at 50% visibility with{surface, hasContext}./app(src/app/app/page.tsx, still'use client'for the Capacitor export) branches on the payload: Android auto-redirects throughplayStoreUrlWithReferrer, iOS renders the two store buttons and writes the clipboard hand-off inside the tap, a bare/appbehaves exactly as today, and the native app applies the payload and routes todest. It also reads thes=tag back and reports it asqr_surface, so a scan is attributable to the QR that produced it.StoreBadges→StorePair: real anchors overstoreAnchorHref+onStoreAnchorClick, both of which take an optional hand-off. Appearancescompact/hero/stacked/footer.StickyMobileCTA: a desktop-mode phone (deviceType === WEB) gets both stores instead of a guess./appand/app/*added to every appID inapple-app-site-associationand to the Android intent filter,appadded toNATIVE_EXPORT_ROOTS, and/app/*collapsed to/appin the deep-link mapper.MIGRATION_SURFACES:smart_link,landing_app_fold,landing_footer,landing_rates,landing_countries,landing_door.Hero and page
AppModalProviderinLandingPageClientwraps the server slots, with ONE hoistedScanToDownloadModalthat every re-pointed CTA opens with its own calling surface.href="/app"upgraded to the deep link on mount, plus an "Other store" link back to/app.max-h-[calc(100svh-29.5rem)]for the hero artwork andcalc(100svh-34.5rem)for the mascot — applied only with the flag on, so the flag-off hero keeps today's exact geometry. Mascot gained aResizeObserveron#hero.'use client'child) open the hoisted modal on desktop withdest=/send, and deep-link the store on a phone.LANDING_HERO_QR_FLAG = 'landing-hero-qr'exported for a later split.Fold 10 and the footer
FooterGetTheApp, opt-in viashowGetTheAppso it stays on the landing page and off /careers, /m/[slug] and /shhhhh): heading, hint, the shadowless store pair on black, and a 224px QR.SEOFootergains crawlable store-listing links in Learn More, gated on the cutover date rather than the client-only flag.Scroll-jack
isScrollFrozen,virtualScrollY, the wheel/touchpreventDefaulthandlers,document.body.style.overflow,handleScrollDelta, thebuttonScalegrowth and its prop plumbing throughhero.tsx, the#sticky-button-targetanchor and the dead--cta-scalecustom property. Fold 10 keeps itscta-motion/cta-enterentrance;id="send-in-seconds"is untouched./shhhhh
/setup: desktop opens the modal with{dest: '/card'}, phones deep-link the store with the same context.useGuestStoreHandoffgained an optionalsurfaceso both halves of the door reportlanding_door. Covers all four door affordances and the campaign-tagged/setup?step=signuppush, and the badge-campaign tag now rides the hand-off.Dedupe done in this PR
src/components/Migration/AppQrCode.tsx,AppStorePair.tsxandAppQrCode.test.tsxare deleted.DownloadQRandStorePairare the single implementations;grepfor either old name returns nothing.DownloadQRgainsbare(frame only, for the three landing lockups, which place their own hint and pair) andclassName, and keeps thedata-testid="app-qr-code"the e2e spec addresses;StorePairgains thefooterappearance andclassName.AppStorePair'srow/column/footermap tohero/stacked/footer— with the fold-10stackedcall site now behind a device guard, sincestackedcollapses to one store on a known platform andcolumndid not (divergence 12).ScanToDownloadModal's privateHandoffDownloadQRwrapper is gone — one line,<DownloadQR surface handoff />.useGuestStoreHandoffpasses the hand-off, not a pre-built payload.AMBIENT_HANDOFF(a frozen emptyStoreHandoff) so the three landing QRs keep carrying locale and invite context with no destination of their own — without making everyDownloadQRcontext-bearing.MIGRATION_SURFACESlist, no duplicate i18n keys, no doubled analytics constants (that file is untouched), oneisPwaSunsetOnlocalStorage override — the non-production-host one, with its tests.How verified
pnpm exec tsc --noEmitTS2307 Cannot find module(web-vitals ×3,@capacitor/app-launcher,@capgo/capacitor-in-app-review,capacitor-native-settings), all in files this PR does not touch. Zero errors in the diff.pnpm test -- --runInBand src/components/Migration src/components/LandingPage src/app/app src/app/shhhhh src/hooks src/utils/__tests__app-review.test.ts,native-settings.test.ts) — both untouched by this branch.pnpm lint --quiet <45 changed files>prettier --check <changed files>The worktree symlinks
node_modulesto the shared checkout, which is physically missing the four native-only packages above — the documented worktree artifact all four source branches recorded.Re-run after the round-3 review fixes (
1aff3601a), one heavy process at a time:pnpm typecheck→ the same six pre-existingTS2307, none in the diff.pnpm test -- --runInBand src/components/Migration src/components/LandingPage src/i18n→ 34 suites, 319 tests green, andsrc/hooks src/utils/__tests__/migration.utils.test.ts src/app→ 108 suites, 1100 green / 3 skipped.eslint --quietandprettier --checkclean on all 19 changed files. New cases: the iPad store column, the desktop pair, the fold's heading level, andmarketing-messages.test.tsre-passing against the regenerated subsets.Tests updated because behaviour changed on purpose:
SendInSecondsBody.test.tsx(the#sticky-button-targetassertion flipped totoBeNull()— the page branch's new file had re-introduced the anchor the scroll-jack branch deleted),LandingPageClient.scrollJack.test.tsx(mocked the renamedStorePair),useGuestStoreHandoff.test.tsx(asserts the modal'shandoffprop, notpayload), and three lockup tests taught the newAMBIENT_HANDOFFexport.AppQrCode.test.tsx's two unique cases were migrated intoDownloadQR.test.tsx.No browser pass in this session: one heavy process at a time is a hard limit here, and the page branch documented that a dev server plus an attached browser is OOM-killed on this box mid-run, producing captures that were silently flag-off pages. The layout claims are asserted by
e2e/flows/landing-migration.spec.ts(4 locales × 1440/1366/390, flag off and on, no horizontal overflow, marquee inside the fold), which CI runs against a production build where the flag override works.Divergences
Full merged log:
run/DIVERGENCES-funnel.md. Each entry there carries its reasoning.From this consolidation pass
AppQrCode/AppStorePairdeleted,DownloadQR/StorePairsurvive. The page branch's own divergence 1 asked for exactly this. Behaviour ported:bare,className, theapp-qr-codetestid, thefooterappearance, the layout mapping above. Reason: one implementation per concern, and the survivors are the ones carrying the hand-off.AppQrCode'ssizewas the module area,DownloadQR's is the frame width. Mapped onto the frames the mechanism branch's amended divergence 3 prescribes — hero 196→192px frame (160→156px modules), fold and footer 228→224 (192→188). Reason: adding 196 and 228 toFRAME_WIDTHwould keep two size vocabularies alive for 4px.AMBIENT_HANDOFFadded.AppQrCodealways built a deferred payload;DownloadQRonly does so with a hand-off, so a bare landing QR would have dropped a /pt-br visitor'slangand their invite cookie. Reason: fixing it by making everyDownloadQRcontext-bearing would change the modal, the home banner and the guest flow, which are deliberately bare.DownloadQR's scan hint moved into aScanHintchild. Reason:barerenders no copy, and callinguseTranslationsunconditionally would drag aNextIntlClientProviderrequirement into three lockups, one of which renders outside one.useGuestStoreHandoffpasses a hand-off, not a pre-built payload. Reason: the shhhhh branch's divergence 5 says to drop itspayloadprop if the mechanism branch lands first. One derivation instead of two.#sticky-button-targetremoved fromSendInSecondsBody.tsx. Reason: the page branch wrote that file against adevthat still had the scroll-jack, silently undoing the scroll-jack branch's deletion — a conflict no merge algorithm could see, and it would have failed that branch's own regression test.customCta→ the page branch'sHeroAppLockup/PhoneAppCtasplit (covers both devices).LandingPageClientscroll machinery → deleted wholesale, including the mechanism branch'smigrationOnrelease valve, which existed only to unfreeze machinery that is now gone.hero.tsx→ the page branch's props minusbuttonScale.From the consolidation review (round 3) — seven findings, all applied; two of the entries above were corrected in the log because the log itself was the thing at fault.
AppModalProviderstatic-importedScanToDownloadModal(→DownloadQR→QRCodeWrapper→react-qr-code) and the provider wraps every landing fold;FooterGetTheAppstatic-importedDownloadQRandFooterChromeimports that block on every page carrying the footer. So the QR library shipped to 100% of landing traffic with the flag off and thessr:falsewrappers on the hero lockup bought nothing. Both are nowdynamic(..., { ssr: false });react-qr-codeis reachable from the landing page only through the four lazy boundaries theLandingPageClientcomment now names.StorePairstays static on purpose —StickyMobileCTAalready puts it in the main chunk.DownloadQR'spayloadprop deleted. Divergence A4 chosehandoffand migrated every production caller, but the prop and its discriminated union survived, alive only in two tests — two ways to feed one QR its context.DownloadQRPropsis now a flat object withhandoff?: StoreHandoff, and the two cases assert the derived querystring instead of a literal.migration.otherStore/migration.getTheAppdeleted from the app catalogs. They were dead: every consumer readsstrings.migration.*from the URL-locale catalog, andStickyMobileCTAreads onlydownloadNow. Removed from{en,es-419,pt-BR}.jsonand the marketing subsets regenerated withscripts/generate-marketing-messages.js. Divergence 14's claim that in-app surfaces still read them was wrong and is corrected in the log.md:flexis 768px, which an iPad clears, whileStorePair appearance="stacked"picks its stores from the UA-baseduseDeviceType()— so a tablet got one store button where the pre-dedupecolumnrendered both, next to a QR it cannot scan.GetTheAppFoldnow takesFooterGetTheApp'sdeviceType === DeviceType.WEBguard and falls through toPhoneAppCta. Divergence A1's claim that everycolumncall site was desktop-only is corrected in the log.h2, not a secondh1. Pre-migration fold 10 had no heading element at all, so the flag-on<h1>GET THE APP.</h1>was a new second h1 under the hero's. Same classes,h2tag, level pinned by a test.SendInSecondstakessubtextas a prop. It was callinggetLandingContent(locale)a second time per page render — an uncachedreadFileSync+ frontmatter parse — to reach a valueLandingPageContentalready holds.SEOFooterstore links are gated at BUILD time, not runtime.storeListings()readsDate.now()inside a statically prerendered server component, so the value freezes into the HTML at build. The links appear at the first deploy on or afterMIGRATION_CUTOVER_DATE, not at the cutover instant — shipping them needs a deploy after that date (a redeploy of the same commit is enough). The date gate is kept (divergence 12's reasoning stands: the flag is client-only and these links exist to be crawlable); the comment aboveSTORE_LISTINGSand the two test names now say so explicitly instead of reading as a runtime claim.Carried from the four branches — mechanism:
/appmade a mappable native route;handoffthreaded through the store helpers;size= frame width, +224;/app/*collapsed in the mapper;/appreads thes=tag. Page: hero clamps gated on the flag; content-system headline kept; footer store links untranslated and cutover-gated; no unit test for the rates re-point; spec output path from the environment; localStorage override widened to every non-production host; the modal'shandoffprop and the two extended helpers; migration copy duplicated into the marketing catalog for URL-locale resolution; the footer block made opt-in; the hero Log In link re-pointed at/app. Scroll-jack: dead--cta-scaleremoved; the two wrapper elements removed; centring asserted numerically instead of by screenshot diff. Shhhhh:surfaceoption on the hook instead of the door callingopenStore; the campaign-tagged path intercepted too; the door's impression event gated on settled auth.New copy is marked copy pass pending: the five
migration*keys in the URL-locale catalog (en,es-419,es-arvoseo,pt-br). There is one copy of each string — the app-catalog duplicates added in round 1 were deleted in round 3 (divergence 11 above).Flag-off impact
None on the landing page: every new surface is behind
useMigrationFlag(), which is false until mount and false in SSR, and the re-tuned hero clamps are applied only when it is true. TheSEOFooterstore links are gated onMIGRATION_CUTOVER_DATE, not the flag, so they stay out of the pre-cutover HTML too — at build time, so they need a deploy on or after that date to appear, and from that build on they render for flag-off visitors on every page mountingSEOFooter(divergence 15).Two changes ship unconditionally, both deliberate:
/appand/app/*on both platforms, and/appis a mapped native route. A bare/appbehaves exactly as today; the only behaviour change is that an installed user openingpeanut.me/app/...lands in the app rather than the browser.Supersedes #3009 #3010 #3011 #3012.