feat(mascot): move the mascot to Lottie and drop 6 MB of rasters - #2728
feat(mascot): move the mascot to Lottie and drop 6 MB of rasters#27280xkkonrad wants to merge 7 commits into
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.
|
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: Pro Plus 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: 7161.37 → 7169.46 (+8.09) 🆕 New findings (112)
…and 92 more. ✅ Resolved (107)
…and 87 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
Canary screenshots — 24 before/after pairs📸 Full canary — all 24 pairs, side by side Captured against production builds of both branches, served locally and driven with Playwright at device scale 2. Left of each pair is the WebP shipping today; right is the same route, same viewport, on this branch. 8 screens × 6 mobile widths:
What the canary caughtIt earned its keep — the first capture pass exposed two regressions that the type checker and 3068 unit tests were both blind to:
Both fixed in
The two Not coveredcheering, pointing, pointing-down and walking sit behind auth or seeded state (claim success, payment success, physical card, marketing CTA), so no canary shot reaches them. They have component tests and appear in |
Independent visual QA — verdict: safe to shipAn independent pass read all 24 before/after pairs plus 10 high-magnification crops. Summary of what came back. Clean
The sharpness win is real, not just differentThree zoom pairs, all decisive:
Most visible at 414/430, where the raster was being upscaled hardest. One judgement call — the hero, and it's yours not mine👉 See it side by side at 430 / 375 / 320 The whistling sprite held its arms on a diagonal — one down-left, one up-right. The Worth being precise about the scope: the headline is decorative artwork, not copy. "GLOBAL CASH" and "TAP. SCAN. ANYWHERE." are untouched, and both builds already overlap it deliberately — the hero comment in I have deliberately not changed it. It's a composition call for whoever owns the hero, and the lever is that component's own box rather than anything in Two smaller notes, both nitpicks, neither a defect: the mascot sits ~10% larger and slightly right-of-centre on maintenance and setup-signup, and setup-finish is arguably improved — the old raster flung one arm toward the bottom-left corner, the rig is symmetrical. |
…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.
Adversarial code review — 2 real bugs found and fixed (
|
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.
Ready for human review7 commits, all signed. CI green. This went through the full Gate
Nothing skipped or deleted to get green. The extra 11 tests are the component's own. What review caught, and what it costFour separate passes ran over this — two adversarial subagents, the
Mascot sizing is now standardizedThe original problem was that nothing was framed consistently — the WebP sprites were cropped tight, the Lottie comps carry per-pose padding, and square boxes fit the wider of a pose's two dimensions. Three things fix it:
Measured on the running app:
Two things for the reviewer, not defects
Caveat on the canaryThe 24 before/after screenshots were captured before the last two commits, so they show the earlier sizing. The measured table above is current. Say the word and I'll re-shoot them. Not coveredcheering, pointing, pointing-down and walking sit behind auth or seeded state, so no canary shot reaches them. They have component tests and appear in |
|
@0xkkonrad saw this PR. Not sure this is the right move. In Capacitor the animation runs in the WebView as JS. lottie-web rasterizes every frame on the main JS thread (the SVG renderer is worse — DOM churn per frame). For a full character at 40+ frames in the Android WebView that is plausibly worse than the GIF we're currently having (we had to convert webp to gif for mobile apps as webp were losing frames). If the assets are ready I can add lottie-ios / lottie-android plugins to Capacitor to see how it will look. But most likely it will be a huge headache. The WebView renders all web content into one native surface. A LottieAnimationView is a sibling view added to the view hierarchy, so it will sit unconditionally above the entire WebView, with no way to interleave.
|
Canary, reshot against the current branchReplaces the earlier set, which predated the sizing work. Every pair below is the same route at the same viewport — WebP on the left, Lottie on the right. What changed since the last setThe mascot is now sized by height, per kind of screen, instead of by a square box that fit whichever of the artwork's two dimensions was wider:
So the invalid-invite mascot is now double the raster it replaced and in the same family as the screens either side of it, and the setup steps are back above their pre-migration height. The one composition call left for a humanThe whistling sprite held its arms diagonally; Coverage and method, honestly
Images live on |




Moves the mascot from animated WebP/GIF to Lottie. Vector art scales to any size, the files are far smaller, and playback becomes something we control at runtime instead of something baked into a sprite.
Batch 2 of the rigs landed last week, so the set now covers all ten animated mascots one-for-one and the whole surface can move at once.
What changed
New component —
src/components/Global/PeanutMascot/import()inside the mount effect, so nothing enters the SSR graph.MASCOT_HOLD_FRAMES = 2,MASCOT_SPEED = 1.30, the values picked in July. Nothing is baked into the files, so re-tuning the feel is a constant, not a re-export.requestAnimationFrameloop drives every instance on the page and stops when the last one unmounts. Ticking is gated byIntersectionObserver, andprefers-reduced-motionholds a static frame.Sizing comes from the artwork, not the canvas. The comps carry different per-pose padding, so sizing the SVG to its 1050×1000 canvas renders every pose at a different apparent size — measured at −20% to +23% against the WebP each one replaces.
MASCOT_ART_BOXESholds each pose's art bounding box and the component places by that instead.MASCOT_ART_FILLis now the single knob for mascot size across the entire app.Call sites. Most were a direct swap. Six passed the image URL through a data structure and needed the structure to carry a pose:
{ src }or{ pose }— three steps use a still, five use a mascot — andSetupWrapperrenders whichever it gets.InvitesPageLayouttakes a pose rather than an image URL; both callers only ever passed mascots.mascotPose.RejectionAssetD3deliberately stays on rasters. It is drawn with D3 and captured into a shareable image; a live Lottie cannot be serialized through that pipeline. The three poses it uses keep their WebP behind a dedicated barrel export, commented on both sides.Removed — 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
isLegacyWebKitgoes with them.getIOSMajorVersionstays, still used byinstrumentation-clientfor Sentry replay gating.Added to
/dev/componentswith all ten poses, per the house rule that the showcase carries every component and variant.Payload
lottie_light, SVG renderer only)Lottie JSON compresses about nine times over; WebP and GIF are already compressed and do not shrink further.
Verification
Local gate, measured against
origin/devas the baseline:tsc --noEmitnpm testprettier --checknext buildNothing was skipped or deleted to get green. The extra suite is the component's own tests.
Rendered size, real screens, before vs after
Both builds were served side by side and the rendered artwork height measured on each — opaque-pixel bounds for the raster,
getBBoxfor the vector:The first measurement pass caught two real regressions, both now fixed in
38e751f: the setup and invites heroes had swapped a square sprite's box for an unrelateddvhheight and lost 14–18%, andMASCOT_ART_FILLsat at the median of the old per-pose fills rather than the top of the range.The two remaining
waving-chillrows are expected and not a defect: that rig is genuinely wider than the whistling sprite it replaces (art aspect 1.21 vs 1.01), so in the same square box it trades height for width.Canary screenshots
24 before/after pairs — 8 screens × 6 mobile widths (320 / 360 / 375 / 390 / 414 / 430), captured against production builds of both branches. Every after-shot mounts a Lottie and none still render a raster. Screens covered: landing hero, careers, maintenance, setup landing, setup signup, setup finish, invite, invalid invite.
Worth a reviewer's judgement
PeanutWhistling→waving-chillandPeanutCrying→worried; there is nowhistlingorcryingrig in the set.worriedis the teary pose and now carries the maintenance screen, claim errors and empty states. Worth an opinion on whether teary is the right register there./dev/components, but no reviewer has seen them on their real screen yet.MASCOT_ART_FILL = 0.93was chosen so no screen ends up smaller than before. Raising it is a one-line change if we want the mascots larger overall.