TASK-21683: validate Lottie mascot in native WebViews - #3219
Conversation
The raster mascots cannot scale and cannot be driven at runtime. This adds the vector player they get replaced with: lottie-web's light SVG build, the ten rigs, and the vintage stutter applied at runtime rather than baked into the files. Sizing comes from each pose's ART bounding box, not the 1050x1000 canvas. The comps carry different per-pose padding, so canvas sizing renders every pose at a different apparent size — measured at -20% to +23% against the WebP it replaces. MASCOT_ART_FILL is the single knob for mascot size across the whole app. One rAF clock drives every instance on the page, gated by IntersectionObserver and disabled under prefers-reduced-motion. The player and each pose's JSON load through dynamic imports, so nothing enters the SSR graph and a screen downloads only the pose it shows. Call sites still reference the removed barrel exports; they move in the next commit.
Replaces the animated WebP/GIF mascots across all call sites. Most are a direct
swap; a few passed the image URL through a data structure and needed the structure
to carry a pose instead:
- Setup steps now describe an illustration as {src} or {pose}, because three steps
use a still and five use a mascot, and SetupWrapper renders whichever it gets.
- InvitesPageLayout takes a pose rather than an image URL; both callers only ever
passed mascots.
- Home carousel CTAs take a mascotPose.
RejectionAssetD3 deliberately stays on rasters. It is drawn with D3 and captured
into a shareable image, and a live Lottie cannot be serialized that way, so the
three poses it uses keep their WebP behind a dedicated barrel export.
Removes 10 GIFs and 7 WebPs. The GIF fallbacks existed only because old WebKit
cannot animate WebP; lottie-web's SVG renderer has no such problem, so
isLegacyWebKit goes with them. getIOSMajorVersion stays — instrumentation-client
still uses it for Sentry replay gating.
Adds the component to the /dev/components showcase with all ten poses, per the
house rule that the showcase carries every component and variant.
… slightly Measured the rendered artwork height on real screens against the old rasters and found two problems. The setup and invites heroes had lost 14-18% of their height. Both had swapped a square sprite's box for an unrelated dvh height. The sprite was 320x320 and sized by width with object-contain, so aspect-square restores the box it actually occupied. The rest were 3-6% short because MASCOT_ART_FILL sat at the median of the old per-pose fills rather than the top of the range. At 0.93 no screen ends up smaller than before while the set still renders at one consistent size. Verified on 7 screens across 6 mobile widths: landing +5%, careers +2%, maintenance +2%, setup signup +7%, invalid invite -2%. The two waving-chill setup heroes remain ~13% shorter because that rig is genuinely wider than the whistling sprite it replaces, so it trades height for width in the same box.
…e qr-pay mascot Two findings from review. The lazy loader had no catch. The app installs a global unhandledrejection handler that reloads the page on ChunkLoadError, so a mascot chunk failing to fetch — during a deploy, or on a flaky connection — would reload the screen under the user. That lands on terminal screens like claim and payment success, discarding their state, because a decorative asset could not be fetched. The old <img> failed silently; so does this now. The qr-pay mascot lost its centring. Its old <Image layout="fill"> made next/image emit an inline inset:0, which silently overrode the h-32 w-32 classes and stretched it across the wrapper. The new host honours those classes, so with no insets it fell back to the wrapper's top-left corner. It now centres explicitly. Also asserts that every rig is authored on the canvas the art boxes were measured against. Re-exporting one comp at a different size would otherwise mis-centre that pose with the whole suite still green. Writing it turned up that 'walking' reaches 16.8 units past the bottom edge mid-stride — harmless, since the art box drives placement and carries the overflow with it, and the old sprite came off the same comp.
The mascot came out a different height on nearly every screen. Two causes. A square box fits the wider of the artwork's two dimensions, and the poses run 0.55 to 1.21 wide-to-tall, so 'waving-chill' rendered 23% shorter than 'thinking' in the same box — that is the setup landing and finish steps, which both use it. And the setup hero sized itself as a percentage of a container that grows and shrinks with the white content panel below, so the same pose came out smaller on the steps whose panel carries more copy. The host now takes its aspect from the pose's art box, so a call site can give a height alone and every pose renders at that height. Two shared constants replace the per-call-site boxes: MASCOT_HERO_CLASS for the screens the mascot leads (setup steps, invite, waitlist) and MASCOT_STATE_CLASS for the ones where it sits above a headline. The invalid-invite screen was on 96px against 128px everywhere else in that second group; it joins them. Measured on the setup finish step at 390x844: 220px tall before, 274px after, and no longer a different size from the invite page next to it.
The mascot still changed size between screens that should have matched. State screens — the invalid-invite and claim errors, card status, the physical-card waitlist — drew it at 118px, against 208-301px on the setup steps a user had just walked through. They carry a headline, a line of copy and a button and nothing else, so there is room: 12rem puts the mascot in the same family without competing with the headline. Measured on the invalid-invite screen it goes 118px to 178px, against 89px for the raster it replaced. The delete-account modal no longer shares that constant. It is an icon in a fixed 8rem slot rather than a mascot the screen is built around, so it sizes to the slot. The claim and payment success screens and the empty state were still on square boxes, which fit the wider of the artwork's two dimensions and so render a wide pose shorter than a narrow one. They now give a height and let the pose take its own width, the same rule the hero and state screens follow. Hero screens now sit within 3-10px of each other across 375, 390 and 430.
…scroll
Four things review caught.
The careers mascot was sized by width inside a half-width column, so it grew with
the viewport instead of staying put — around 1000px tall on a desktop, against an
intrinsic 320px sprite before. It now takes a height like every other screen.
The landing hero measured its host box to place the peanut's feet 6% into the
headline, but the drawing fills 93% of that box and is centred in it, so the feet
floated above the headline by the leftover padding instead of overlapping it. It
now measures the artwork.
The virtual clock lived in the effect, so it reset every time IntersectionObserver
toggled. Scrolling a mascot out and back restarted its loop, and a loop={false}
one-shot would replay on each re-entry. It survives in a ref now.
The showcase's sizing callout still warned that a height-only class renders nothing,
which stopped being true when the host started taking its aspect from the pose — and
contradicted MASCOT_STATE_CLASS, which is height-only and on five screens.
# Conflicts: # src/app/(mobile-ui)/dev/components/page.tsx # src/app/(mobile-ui)/dev/home-ctas/page.tsx # src/app/(mobile-ui)/qr-pay/page.tsx # src/app/maintenance/page.tsx # src/assets/mascot/index.ts # src/components/Card/ApplicationStatusScreen.tsx # src/components/Card/PhysicalCardScreen.tsx # src/components/Card/share-asset/RejectionAssetD3.tsx # src/components/Claim/Generic/ClaimError.view.tsx # src/components/Claim/Link/Onchain/Success.view.tsx # src/components/Global/ConfirmInviteModal/index.tsx # src/components/Global/EmptyStates/NoDataEmptyState.tsx # src/components/Global/NoMoreJailModal/index.tsx # src/components/Home/HomeCarouselCTA/CarouselCTA.tsx # src/components/Home/HomeCarouselCTA/index.tsx # src/components/Invites/InvitesPage.tsx # src/components/Invites/InvitesPageLayout.tsx # src/components/Invites/JoinWaitlistPage.tsx # src/components/Jobs/index.tsx # src/components/LandingPage/hero.tsx # src/components/Payment/Views/Error.validation.view.tsx # src/components/Settings/DeleteAccountButton.tsx # src/components/Settings/__tests__/DeleteAccountButton.test.tsx # src/components/Setup/Setup.consts.tsx # src/components/Setup/components/SetupWrapper.tsx # src/features/payments/shared/components/PaymentSuccessView.tsx
|
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: Advanced 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: 7997.19 → 8007.82 (+10.63) 🆕 New findings (131)
…and 111 more. ✅ Resolved (122)
…and 102 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
🖼 Visual diff — 4 screens moved8 of 96 shots changed · 88 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
One major finding: the new iOS profile build cannot resolve its native version from the workflow's shallow checkout.
Findings
-
MAJOR · .github/workflows/ios-release.yml:114 · Fetch full history before resolving the iOS profile version
A blank profile dispatch reachesrelease-version.mjs native-floor, but the iOS checkout still uses the default depth of 1. The exact-head App Release iOS run fails on this line withrelease-version: shallow clone, so it never builds or uploads the TestFlight profiler described by this PR. Setfetch-depth: 0on the iOS checkout, as the Android workflow already does, and assert that requirement in the workflow test. -
MAJOR · .github/workflows/android-release.yml:177 · [moonshotai/kimi-k3] Debuggable signed builds share the production version and can be promoted to production
The profile mode produces a production-signed app (Android: same versionName as the current native floor, uploaded to Play internal; iOS: same marketing version, uploaded to TestFlight) with WebView remote inspection enabled (WEBVIEW_DEBUG / CAPACITOR_DEBUG) and a live /dev/lottie-profile route on the production origin. Because the build reuses the production versionName and the production signing identity, nothing in the artifact itself prevents it from being promoted from Play internal to a higher track or from TestFlight into App Store review — a single mistaken click in Play Console/App Store Connect ships a remotely-inspectable WebView for peanut.me to real users, which on a wallet app exposes anything visible in the WebView (session state, passkey flows, balances) to anyone with device debugging access. Fix: stamp profile builds with a hard distinguisher that makes promotion impossible — e.g. a different applicationId/bundle-id suffix (me.peanut.wallet.lottieprofile), or a different versionCode range / prerelease version string that fails the store's promotion checks, and have the app refuse to boot outside an explicit profile flag baked only into those builds.
Checked clean
- Pinned head, supplied base, and merge base all match the requested SHAs.
- Mascot loading, shared animation clock, offscreen pause, reduced-motion behavior, sizing, failure isolation, and migrated UI consumers.
- Native export pruning, profiler route gates, and deep-link routing keep unrelated production dev routes closed.
- Android profile release passed at this head, uploaded only to Play internal, skipped production OTA publication, and skipped the replacement tag job.
- Exact-head typecheck, ESLint, unit, design-system lint, and design-system screenshot checks passed; screen-library publishing failed on Cloudflare token verification outside this diff.
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: 75a1610ad1d7 · Context: repo, ci · Took 21m
There was a problem hiding this comment.
Chip review — no blocking findings — this is not an approval
The iOS history issue is fixed, but the Android profiling artifact remains eligible for production promotion.
Findings
-
MAJOR · .github/workflows/android-release.yml:282 · Make the inspectable Android artifact impossible to promote
This profile path still builds the production-signedme.peanut.walletrelease with the current production versionName, a fresh higher versionCode, and WebView debugging enabled. Restrictinginputs.trackonly chooses the initial Play track; the same internal-test AAB can later be promoted to production. If that happens, users receive an inspectable build containing/dev/lottie-profile, and the shared versionName gives the OTA lane no way to distinguish it from normal binaries. Build the profiler with a non-production application ID/flavor or distribute it through a separate non-promotable app/Internal App Sharing path. -
MAJOR · .github/workflows/ios-release.yml:323 · [moonshotai/kimi-k3] Inspectable signed iOS build ships under the production version
For dispatches from the profile branch, CAPACITOR_DEBUG=true is passed into the xcodebuild archive of the production app (same bundle id, signing, and reused marketing version via native-floor), enabling WebView inspection in a TestFlight-signed build. Anyone who obtains the build can attach a Web Inspector to the live WebView and read/modify in-page state on a production-identity wallet app, and because the version matches production the artifact is hard to distinguish from a real release. This is the iOS counterpart of the Android debuggable-build issue (P2). Fix: build profile/TestFlight builds with a distinct bundle id or at minimum a distinct marketing version suffix, and gate CAPACITOR_DEBUG behind a configuration that cannot be promoted or re-signed as production.
Checked clean
- Exact detached head, merge base, trusted author, base ref, and PR metadata matched the supplied values.
- P1 is fixed: the iOS checkout now uses fetch-depth 0 before native-floor tag resolution.
- Reviewed the mascot loader, shared animation clock, resize/intersection/reduced-motion behavior, per-pose assets, and migrated call sites.
- Reviewed the native profiler gate, static-export pruning, deep-link allowlist, iOS and Android release guards, and upload paths.
- Exact-head typecheck, lint, unit, screen-test, and native-export CI checks passed; visual captures and the dispatched iOS profile release were still running.
- A local focused Jest rerun was unavailable because this detached worktree has no installed Jest binary; the exact-head unit CI job passed.
- Bundled Lottie JSON files contain no remote asset URLs.
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: 3edacd7c4bdd · Context: repo, ci, web · Took 14m
What this does
dev, preserving the newer setup, carousel, drawer, QR-pay, landing-height, and account-state changeslottie-webplayer, including offscreen pause, reduced-motion behavior, one shared animation clock, and load-failure isolation/dev/lottie-profilefor real Capacitor WebView measurements (1/3/10 instances, rAF timing, slow frames, long tasks, renderer readiness, and device metadata)Why this is draft
TASK-21683 requires measurements in the real Android WebView before choosing live Lottie or generated raster output. Desktop profiling is only a proxy. Do not merge this PR until the floor-device and thermal-repeat runs are recorded.
Validation
pnpm typecheck(Node 22)pnpm lint(0 errors; existing warnings only)actionlintfor the changed iOS/Android workflows (existing unrelated shellcheck findings ignored)pnpm native:buildsucceeds under Node 22/dev/lottie-profileand/dev/deferred, while unrelated/devtools are prunedDevice protocol
See
docs/LOTTIE-NATIVE-PROFILE.md. The acceptance case is onewalkingmascot on a floor Android device before and after a five-minute thermal run; 3 and 10 instances are stress diagnostics. iOS TestFlight gets awaving-chill1/3-instance sanity run.