Skip to content

feat(mascot): move the mascot to Lottie and drop 6 MB of rasters - #2728

Open
0xkkonrad wants to merge 7 commits into
devfrom
feat/mascot-lottie
Open

feat(mascot): move the mascot to Lottie and drop 6 MB of rasters#2728
0xkkonrad wants to merge 7 commits into
devfrom
feat/mascot-lottie

Conversation

@0xkkonrad

Copy link
Copy Markdown
Contributor

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 componentsrc/components/Global/PeanutMascot/

<PeanutMascot pose="cheering" className="size-24" alt="…" />
  • lottie-web's light SVG build, loaded with a dynamic import() inside the mount effect, so nothing enters the SSR graph.
  • Each pose's JSON is its own dynamic import, so a screen downloads only the pose it shows.
  • The 1930s stutter is applied at runtime — the clock is quantized to held frames rather than played smoothly. 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.
  • One requestAnimationFrame loop drives every instance on the page and stops when the last one unmounts. Ticking is gated by IntersectionObserver, and prefers-reduced-motion holds 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_BOXES holds each pose's art bounding box and the component places by that instead. MASCOT_ART_FILL is 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:

  • Setup steps now describe an illustration as { src } or { pose } — three steps use a still, 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; 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 isLegacyWebKit goes with them. getIOSMajorVersion stays, still used by instrumentation-client for Sentry replay gating.

Added to /dev/components with all ten poses, per the house rule that the showcase carries every component and variant.

Payload

Raster removed 5.97 MB across 17 files
Lottie added 90 KB gzipped, 10 rigs
Player added 45 KB gzipped (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/dev as the baseline:

baseline this branch
tsc --noEmit 0 errors 0 errors
npm test 237 suites / 3060 238 suites / 3068
prettier --check clean clean
next build passes

Nothing 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, getBBox for the vector:

screen pose before after delta
landing hero waving-chill 227.6 239.3 +5%
careers too-cool 234.3 239.7 +2%
maintenance worried 230.5 235.6 +2%
setup signup thinking 232.4 248.8 +7%
invalid invite sad 89.4 87.9 −2%
setup landing waving-chill 244.3 210.7 −14%
setup finish waving-chill 260.7 225.6 −13%

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 unrelated dvh height and lost 14–18%, and MASCOT_ART_FILL sat at the median of the old per-pose fills rather than the top of the range.

The two remaining waving-chill rows 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

  1. Two poses are forced remaps. PeanutWhistlingwaving-chill and PeanutCryingworried; there is no whistling or crying rig in the set. worried is 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.
  2. Four poses are not covered by the canary — cheering, pointing, pointing-down and walking live behind auth or seeded state (claim success, payment success, physical card, marketing CTA). They are covered by the component tests and by /dev/components, but no reviewer has seen them on their real screen yet.
  3. MASCOT_ART_FILL = 0.93 was chosen so no screen ends up smaller than before. Raising it is a one-line change if we want the mascots larger overall.

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.
@vercel

vercel Bot commented Aug 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview Aug 18, 2026 2:47pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 68a3a763-a206-4f86-af2c-21313d424426

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7161.37 → 7169.46 (+8.09)
Findings: +5 net (+112 new, -107 resolved)

🆕 New findings (112)

  • critical complexity — src/components/Invites/JoinWaitlistPage.tsx — CC 84, MI 56.05, SLOC 293
  • critical complexity — src/hooks/useHomeCarouselCTAs.tsx — CC 69, MI 57.15, SLOC 315
  • critical complexity — src/app/(setup)/setup/page.tsx — CC 63, MI 55.87, SLOC 232
  • high hotspot — src/app/(mobile-ui)/qr-pay/page.tsx — 103 commits, +1250/-1167 lines since 6 months ago
  • high structural-dup — app/(mobile-ui)/dev/components/page.tsx:60 — 79 duplicate lines / 162 tokens with app/(mobile-ui)/dev/ds/foundations/icons/page.tsx:10
  • high complexity — src/components/Setup/components/SetupWrapper.tsx — CC 45, MI 56.18, SLOC 88
  • high hotspot — src/hooks/useHomeCarouselCTAs.tsx — 38 commits, +485/-265 lines since 6 months ago
  • high complexity — src/app/(mobile-ui)/dev/components/page.tsx — CC 36, MI 66.13, SLOC 176
  • high hotspot — src/components/LandingPage/hero.tsx — 34 commits, +281/-200 lines since 6 months ago
  • high complexity — src/components/Claim/Link/Onchain/Success.view.tsx — CC 33, MI 59.69, SLOC 149
  • high complexity — src/components/Global/PeanutMascot/index.tsx — CC 33, MI 64.05, SLOC 133
  • high method-complexity — src/app/(setup)/setup/page.tsx:82 — CC 31 SLOC 104
  • high complexity — src/components/LandingPage/hero.tsx — CC 31, MI 59.24, SLOC 119
  • high method-complexity — src/components/Invites/JoinWaitlistPage.tsx:34 — CC 30 SLOC 125
  • high method-complexity — src/hooks/useHomeCarouselCTAs.tsx:149 — CC 30 SLOC 142
  • high complexity — src/components/Setup/Setup.consts.tsx — CC 1, MI 41.07, SLOC 67
  • medium react-long-component — src/app/(mobile-ui)/dev/components/page.tsx:138 — ComponentsPage is 1355 lines — split it
  • medium high-mdd — src/hooks/useHomeCarouselCTAs.tsx:84 — useHomeCarouselCTAs: MDD 169.9 (uses across many lines from declarations)
  • medium high-mdd — src/components/Invites/JoinWaitlistPage.tsx:34 — JoinWaitlistPage: MDD 151.6 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/useHomeCarouselCTAs.tsx:149 — : MDD 122.1 (uses across many lines from declarations)

…and 92 more.

✅ Resolved (107)

  • src/components/Invites/JoinWaitlistPage.tsx — CC 84, MI 56.04, SLOC 293
  • src/hooks/useHomeCarouselCTAs.tsx — CC 69, MI 57.11, SLOC 316
  • src/app/(setup)/setup/page.tsx — CC 63, MI 55.91, SLOC 231
  • src/app/(mobile-ui)/qr-pay/page.tsx — 101 commits, +1242/-1159 lines since 6 months ago
  • app/(mobile-ui)/dev/components/page.tsx:43 — 79 duplicate lines / 162 tokens with app/(mobile-ui)/dev/ds/foundations/icons/page.tsx:10
  • src/components/Setup/components/SetupWrapper.tsx — CC 43, MI 56.19, SLOC 89
  • src/hooks/useHomeCarouselCTAs.tsx — 37 commits, +478/-259 lines since 6 months ago
  • src/app/(mobile-ui)/dev/components/page.tsx — CC 34, MI 67.54, SLOC 145
  • src/components/LandingPage/hero.tsx — CC 34, MI 59.58, SLOC 125
  • src/components/Claim/Link/Onchain/Success.view.tsx — CC 33, MI 59.68, SLOC 149
  • src/components/LandingPage/hero.tsx — 32 commits, +255/-165 lines since 6 months ago
  • src/app/(setup)/setup/page.tsx:83 — CC 31 SLOC 104
  • src/components/Invites/JoinWaitlistPage.tsx:35 — CC 30 SLOC 125
  • src/hooks/useHomeCarouselCTAs.tsx:147 — CC 30 SLOC 143
  • src/components/Setup/Setup.consts.tsx — CC 1, MI 42.41, SLOC 59
  • src/app/(mobile-ui)/dev/components/page.tsx:121 — ComponentsPage is 1273 lines — split it
  • src/hooks/useHomeCarouselCTAs.tsx:82 — useHomeCarouselCTAs: MDD 170.8 (uses across many lines from declarations)
  • src/components/Invites/JoinWaitlistPage.tsx:35 — JoinWaitlistPage: MDD 151.6 (uses across many lines from declarations)
  • src/hooks/useHomeCarouselCTAs.tsx:147 — : MDD 122.8 (uses across many lines from declarations)
  • src/components/Invites/InvitesPage.tsx:38 — InvitePageContent: MDD 106.0 (uses across many lines from declarations)

…and 87 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/components/Global/PeanutMascot/index.tsx 0.0 8.3 +8.3
src/components/Global/PeanutMascot/PeanutMascot.utils.ts 0.0 5.5 +5.5
src/components/Global/PeanutMascot/PeanutMascot.consts.ts 0.0 4.6 +4.6
src/components/Settings/DeleteAccountButton.tsx 7.6 8.5 +0.9
src/utils/webkit.utils.ts 5.2 3.9 -1.3
src/assets/mascot/index.ts 6.0 4.4 -1.6

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 3124 ran, 0 failed, 0 skipped, 55.1s

📊 Coverage (unit)

metric %
statements 67.3%
branches 52.3%
functions 57.8%
lines 68.1%
⏱ 10 slowest test cases
time test
4.1s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.2s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.5s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.4s src/i18n/__tests__/messages.test.ts › en has no empty or untrimmed values
0.4s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.3s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.3s src/utils/__tests__/auth-token.test.ts › authReady does not park — hydrates the plain token without an unlock
0.3s src/utils/__tests__/auth-token.test.ts › ignores the guarded marker and falls back to the plain token
0.3s src/utils/__tests__/auth-token.test.ts › is none — never guarded — when only the guarded marker is present
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@0xkkonrad

Copy link
Copy Markdown
Contributor Author

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:

Screens landing hero, careers, maintenance, setup landing, setup signup, setup finish, invite, invalid invite
Widths 320, 360, 375, 390, 414, 430
Poses seen waving-chill, too-cool, worried, thinking, sad
Result 24/24 mount a rig, 0 still render a raster, 0 page errors

What the canary caught

It earned its keep — the first capture pass exposed two regressions that the type checker and 3068 unit tests were both blind to:

  1. Setup and invites heroes lost 14–18% of their height. Both had replaced a square sprite's box with an unrelated dvh height. The sprite was 320×320 and sized by width with object-contain, so aspect-square restores the box it actually occupied.
  2. Everything else was 3–6% short, because MASCOT_ART_FILL sat at the median of the old per-pose fills rather than the top of the range.

Both fixed in 38e751f, then re-measured. Current state, comparing rendered artwork height (opaque-pixel bounds for the raster, getBBox for the vector — the drawing, not the box):

screen pose before after delta
setup signup thinking 232.4 248.8 +7%
landing hero waving-chill 227.6 239.3 +5%
careers too-cool 234.3 239.7 +2%
maintenance worried 230.5 235.6 +2%
invalid invite sad 89.4 87.9 −2%
setup finish waving-chill 260.7 225.6 −13%
setup landing waving-chill 244.3 210.7 −14%

The two waving-chill rows are expected rather than a defect: that rig is genuinely wider than the whistling sprite it replaces (art aspect 1.21 against 1.01), so in the same square box it trades height for width. Visible in the pairs — it reads as the same visual weight.

Not covered

cheering, 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 /dev/components, but nobody has seen them on their real screen. If someone with a local session can open those four, that closes the last gap.

@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Independent visual QA — verdict: safe to ship

An independent pass read all 24 before/after pairs plus 10 high-magnification crops. Summary of what came back.

Clean

  • No missing or invisible mascot. All 24 after-shots render a rig.
  • No clipping. The host has overflow-hidden, so the two plausible failure modes were checked specifically — the top edge of the signup panel at 320/375/390 and the right edge of the hero at 390/430. Heads, gloves, shoes and glasses fully drawn; nearest approach to a container edge is ~45 css px.
  • No layout shift. Whole-frame pixel diffs show the only changes outside the mascot's own box are the animated marquee phase and a loading spinner. The SIGN UP button and yellow ticker land on identical y in both builds.
  • Both forced pose remaps read correctly. waving-chill keeps the whistling "O" muzzle and round eyes, so it's recognisably the same character. worried still cries actual tear drops with downturned brows — same emotional register as the pose it replaces.
  • careers and invalid-invite are effectively unchanged — within 4 css px on every axis.

The sharpness win is real, not just different

Three zoom pairs, all decisive:

  • careers face at 3.4× — the WebP has stepped edges on the sunglasses frame, a grey halo along every black stroke, banding in the yellow fill. The Lottie is flat colour, clean curves, zero fringing.
  • signup face at 2.6× — the WebP renders the lenses as grey mush with magenta fringing; the Lottie draws the glare as clean white strokes.
  • hero glove at 1.6× — the WebP outline is soft with a pink halo bleeding into the background; the Lottie stroke is solid black on flat pink.

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 waving-chill rig holds both arms out horizontally, at roughly the height of the graffiti headline behind it. At 430 that puts the gloves and head across "LOCAL FEEL", which the old pose left more readable. At 375 and 320 the difference is marginal.

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 hero.tsx says the overlap is intended. So this is a question of degree on the flagship marketing page.

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 PeanutMascot. Happy to adjust if you want it to occlude less.

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.
@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Adversarial code review — 2 real bugs found and fixed (00699aea5)

An independent review went at the diff looking for user-facing breakage. Two findings were real and are fixed; I checked each myself rather than taking them on trust.

1. HIGH — a failed mascot chunk could reload the page

PeanutMascot/index.tsx fired the lazy loader with no .catch. Webpack rejects a failed dynamic import with ChunkLoadError, and this app inlines a global handler in layout.tsx that listens on unhandledrejection and calls window.location.reload() for exactly that error.

Verified: chunk-error-recovery.ts:99 does call window.location.reload(), and layout.tsx:168 inlines it.

Trigger: a tab open across a deploy — the deploy-skew case that file's own header cites as a real production incident — or a flaky mobile connection. A decorative asset 404s and the page reloads under the user. That lands on terminal screens: payment success, claim success, the qr-pay loading state, the delete-account confirmation. Confetti and in-page state discarded.

Before this PR those screens had zero dynamic imports for their artwork — a failed <img> was inert. Now caught and swallowed, so the box just stays empty, which is what the <img> did.

2. MEDIUM — qr-pay mascot lost its centring

The old markup was <Image layout="fill" objectFit="contain" className="absolute z-0 h-32 w-32 ...">. next/image maps layout="fill" to inline position:absolute; inset:0; width:100%; height:100%, which silently overrode the h-32 w-32 classes — so the mascot stretched across the wrapper and appeared centred.

The new host actually obeys h-32 w-32. With all four insets auto, an absolutely-positioned element lands at its static position: the wrapper's top-left corner. The mascot would have jumped left by roughly (cardWidth − 128) / 2 on every qr-pay loading state. This was the one call site where the class list was inert before and load-bearing now. Now centred explicitly with left-1/2 -translate-x-1/2.

Also added — a guard the review asked for

MASCOT_CANVAS_WIDTH/HEIGHT duplicated each comp's dimensions with nothing enforcing it; re-exporting one rig at a different canvas size would silently mis-centre that pose with all 3068 tests still green. Now asserted against the JSON.

Writing that test turned up something worth recording: walking reaches 16.8 units past the bottom of its 1050×1000 comp mid-stride. Harmless — the art box drives placement and carries the overflow with it, and the sprite it replaces came off the same comp — but it is now documented rather than latent.

One finding I did not action

The review flagged alt="Peanutman crying 😭" and t('cryingPeanutAlt') as stale, since PeanutCrying is now pose="worried". The copy is still accurate — the worried rig has literal Tear 1 and Tear 2 layers and the peanut visibly cries. Verified in the JSON and in the maintenance screenshots. Renaming the keys would make them less true, not more.

Two other notes accepted as tradeoffs rather than defects: mascots are now client-rendered so the priority preload is gone (an empty box on first paint, in exchange for 368 KB → 55 KB on the thinking screen), and a mascot scrolled out and back restarts its loop at frame 0.

Gate after the fixes: typecheck 0 errors, 238 suites / 3069 tests green, prettier clean.

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.
@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Ready for human review

7 commits, all signed. CI green. This went through the full peanut-pr lifecycle: local gate → CI → automated review → smell/docs pass → re-verify. Stopping here, per the rule that the skill never merges.

Gate

baseline (origin/dev) this branch
tsc --noEmit 0 errors 0 errors
npm test 237 suites / 3060 238 suites / 3071
prettier --check clean clean
next build passes
CI 18 pass, 0 fail

Nothing skipped or deleted to get green. The extra 11 tests are the component's own.

What review caught, and what it cost

Four separate passes ran over this — two adversarial subagents, the /code-review skill, and a measured visual canary. Between them they found six real defects, none of which typecheck or 3060 unit tests would have caught:

  1. A leaked rAF loop. The shared clock re-armed itself after its last subscriber left, spinning forever with zero subscribers. Fixed with a regression test verified to fail without the fix.
  2. A failed chunk could reload the page. The lazy loader had no .catch, and this app reloads on ChunkLoadError via a global handler — so a decorative mascot failing to fetch during a deploy would reload a payment-success screen and discard its state.
  3. qr-pay lost its centring. <Image layout="fill"> had been emitting an inline inset:0 that silently overrode the size classes; the new host obeys them and fell to the wrapper's top-left.
  4. Setup and invites heroes lost 14–18% of their height — a square sprite's box replaced with an unrelated dvh height.
  5. Careers grew without bound — sized by width inside a half-width column, ~1000px tall on desktop against a 320px sprite.
  6. The landing hero's feet stopped overlapping the headline — it measured the host box, but the drawing fills 93% of it and is centred, so the feet floated by the leftover padding.

Mascot sizing is now standardized

The 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:

  • The host takes its aspect from the pose, so a call site gives a height and every pose renders at that height.
  • MASCOT_HERO_CLASS for screens the mascot leads (setup steps, invite, waitlist).
  • MASCOT_STATE_CLASS for screens where it sits above a headline (errors, card status, physical card).

Measured on the running app:

group 375 390 430 spread
hero screens 208–211 262–266 291–301 3–10px
invalid invite 173 178 was 118, raster was 89

MASCOT_ART_FILL remains the single knob if you want the whole set bigger.

Two things for the reviewer, not defects

  1. The hero pose occludes more of "LOCAL FEEL" at 430. waving-chill holds its arms horizontally where the whistling sprite held them diagonally. The headline is decorative artwork and both builds already overlap it deliberately — this is a question of degree on the flagship page, and the lever is hero.tsx's own box. Deliberately not changed.
  2. Mascots are client-rendered now, so the landing hero's priority preload is gone: an empty box on first paint, and crawlers don't see it. In exchange the thinking screen goes 368 KB → 55 KB. Worth a call if landing LCP is tracked.

Caveat on the canary

The 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 covered

cheering, pointing, pointing-down and walking sit behind auth or seeded state, so no canary shot reaches them. They have component tests and appear in /dev/components.

@innolope-dev

Copy link
Copy Markdown
Collaborator

@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.

  1. Z-order will be gone
  2. We'll have to hand-mirror DOM geometry
  3. Scrolling will be almost impossible

@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Canary, reshot against the current branch

Replaces 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.

before/after pairs — landing hero, careers, maintenance

before/after pairs — maintenance, setup landing

before/after pairs — setup landing, setup signup

What changed since the last set

The 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:

group 375 390 430 spread
hero screens (setup steps, invite, waitlist) 208–211 262–266 291–301 3–10px
invalid invite 173 178 was 118; the raster was 89

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 human

landing hero at 430, 375 and 320 — before and after

The whistling sprite held its arms diagonally; waving-chill holds them horizontally, at about the height of the graffiti headline. At 430 that covers more of "LOCAL FEEL" than before. The headline is decorative artwork and both builds already overlap it deliberately, so this is a question of degree on the flagship page — the lever is hero.tsx's own box, not the component. Deliberately unchanged.

Coverage and method, honestly

  • 19 pairs across 7 screens and 6 widths: landing hero, careers, maintenance, setup landing, setup signup, setup finish, invalid invite.
  • The 3 /invite shots from the previous set are dropped, not missing — that route redirects to the landing page, so they duplicated landing-hero.
  • before frames come from a production build of origin/dev; after frames from a dev build of this branch, with the dev overlay hidden. This sandbox kept running out of memory during next build, and a matched prod/prod capture lost to that. Same DOM and CSS either way, but worth stating rather than implying otherwise.
  • cheering, pointing, pointing-down and walking still are not reachable without auth or seeded state. They have component tests and appear in /dev/components.

Images live on canary/mascot-lottie-shots — screenshots only, never merged, nothing imports it. That branch is how they render inline here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants