Skip to content

fix(card): bulletproof share-asset capture — faithful shadows + deterministic ready gate#2308

Merged
Hugo0 merged 2 commits into
mainfrom
fix/share-asset-bulletproof
Jun 29, 2026
Merged

fix(card): bulletproof share-asset capture — faithful shadows + deterministic ready gate#2308
Hugo0 merged 2 commits into
mainfrom
fix/share-asset-bulletproof

Conversation

@Hugo0

@Hugo0 Hugo0 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Why

The card launched today (main = prod). The share asset users capture-and-post had two capture bugs, plus a test gap.

1. Square-shadow bug

html-to-image renders CSS box-shadow on a rounded element as a square block, so the captured PNG showed square shadows behind:

  • the peanut.me/<user> username pill (rounded-full) in ShareAssetD3
  • the card drop-shadow (rounded-3xl) in PixelatedCardFace

Fix: replace each box-shadow with an offset black sibling element that shares the same border-radius, shifted by the shadow distance and layered behind. html-to-image renders these faithfully (rounded, not square). The pill's rotation now lives on a wrapper so the shadow tracks the tilt.

Hero stickers use filter: drop-shadow(...) — those capture fine and are left untouched.

2. Deterministic blank-card gate

PixelatedCardFace paints its pixelated hand into a <canvas> appended asynchronously (new Image().onload → appendChild). A capture firing before that canvas mounted snapshotted a blank pink card — and the capture succeeded, so nothing reached Sentry. The bounded waitForAssetReady wait (#2302) can time out under load → still blank.

Fix (deterministic): PixelatedCardFace fires an onReady callback once the hand canvas mounts. It threads up ScaledPixelatedCardFace / ShareAssetD3 / ScaledShareAsset (via the existing prop spreads) to ShareAssetActions, which disables Share/Save until the asset signals ready — a capture can never fire before the card face paints. waitForAssetReady stays as a belt-and-suspenders fallback.

3. Test surface + regression guard

  • Wired the /dev/share-builder "Save image" button (previously a dummy <Button> with no onClick) to the real captureShareAsset + downloadBlob path, gated on the same onReady signal.
  • Added e2e/flows/share-asset-capture.spec.ts: loads /dev/share-builder, waits for the Save button to enable (proves the ready gate releases only after the canvas mounts), clicks Save, intercepts the downloaded PNG, decodes it with sharp, and samples the centre of the card (the hand's territory — away from the top-left logo and bottom-left number). It fails if that region is entirely background (card-pink #FF90E8 / asset-blue #90A8ED), i.e. the hand never rendered.

Files

  • src/components/Card/share-asset/PixelatedCardFace.tsx — card offset-shadow sibling; onReady fired from PixelatedHand after the canvas appends.
  • src/components/Card/share-asset/ShareAssetD3.tsx — pill offset-shadow sibling; forwards onReady to PixelatedCardFace.
  • src/components/Card/share-asset/shareAsset.types.tsonReady?: () => void on ShareAssetD3Props.
  • src/components/Card/share-asset/ShareAssetActions.tsxready? prop gates both buttons.
  • src/components/Card/BadgeSkipCelebration.tsx, CardUnlockDrawer.tsx — track assetReady, pass onReadyready.
  • src/app/(mobile-ui)/dev/share-builder/page.tsx — wired Save button + onReady gating.
  • e2e/flows/share-asset-capture.spec.ts — new regression guard.

Verification

  • pnpm prettier --check ✅ · npm run typecheck ✅ · npm test ✅ (103 suites, 1602 passed, 3 skipped)
  • e2e decode/sample logic validated against synthetic PNGs (blank → 0% non-bg → fails; hand → 45% → passes). Playwright not run locally (the (mobile-ui) layout redirects unauthenticated users to /setup; the spec needs the authenticated harness, which CI provides) — relies on CI for the browser run.

…ministic ready gate

The card launched today; the share asset users post had two capture bugs.

1) Square shadows. html-to-image renders CSS box-shadow on a rounded element
   as a SQUARE block, so the captured PNG showed square shadows behind the
   rounded peanut.me/<user> pill and the card itself. Replace those box-shadows
   with offset black sibling elements that share the border-radius — they
   capture as faithful rounded shadows. (Hero stickers use filter:drop-shadow,
   which captures fine — left as is.)

2) Blank card. PixelatedCardFace paints its pixelated hand into a <canvas>
   appended asynchronously, so a capture firing before the canvas mounted
   snapshotted a blank pink card — and the capture SUCCEEDED, so nothing
   reached Sentry. The bounded waitForAssetReady wait (PR #2302) can time out
   under load. Deterministic fix: PixelatedCardFace fires onReady once the hand
   canvas mounts; it threads up through ShareAssetD3 to the Share/Save buttons,
   which stay disabled until the asset signals ready. waitForAssetReady stays
   as a belt-and-suspenders fallback.

Also wires the /dev/share-builder "Save image" button to the real capture path
and adds an e2e regression guard that decodes the captured PNG and asserts the
card centre (the hand's territory) is not entirely background — proving the
card face actually renders.
@vercel

vercel Bot commented Jun 29, 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, Comment Jun 29, 2026 2:36pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 15 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e2441090-b6ab-4a68-9a87-daa7c9cff44b

📥 Commits

Reviewing files that changed from the base of the PR and between b6e187d and 06928d6.

📒 Files selected for processing (1)
  • e2e/flows/share-asset-capture.spec.ts

Walkthrough

Adds an onReady callback that fires from PixelatedHand after its async canvas is painted, propagated through PixelatedCardFaceShareAssetD3 → consumer components (CardUnlockDrawer, BadgeSkipCelebration, ShareBuilderPage). ShareAssetActions gains a ready prop gating Share/Save buttons. A Playwright e2e spec validates the captured PNG is not blank.

Changes

Share asset onReady gating and capture regression test

Layer / File(s) Summary
onReady contract and ShareAssetActions ready prop
src/components/Card/share-asset/shareAsset.types.ts, src/components/Card/share-asset/ShareAssetActions.tsx
Adds onReady?: () => void to ShareAssetD3Props and ready?: boolean to ShareAssetActions Props; Share/Save buttons are disabled when !ready.
PixelatedCardFace/PixelatedHand wiring and shadow refactor
src/components/Card/share-asset/PixelatedCardFace.tsx
Extends PixelatedCardFaceProps with onReady, threads it to PixelatedHand which calls it after canvas append; refactors drop-shadow into a separate absolutely-positioned sibling.
ShareAssetD3 prop wiring and username pill shadow refactor
src/components/Card/share-asset/ShareAssetD3.tsx
Destructures and forwards onReady to PixelatedCardFace; replaces boxShadow on the pill with a separate hidden shadow sibling and wrapper rotation.
Consumer surface wiring
src/components/Card/CardUnlockDrawer.tsx, src/components/Card/BadgeSkipCelebration.tsx, src/app/(mobile-ui)/dev/share-builder/page.tsx
Each adds assetReady state, wires onReady on ScaledShareAsset/ShareAssetD3, and passes ready={assetReady} to ShareAssetActions; ShareBuilderPage also adds handleSave, data-testid, and resets assetReady on seed/animate changes.
Playwright blank-card regression spec
e2e/flows/share-asset-capture.spec.ts
Loads /dev/share-builder, waits for the save button to enable via onReady, downloads a PNG, decodes it with sharp, samples the central card region, and asserts non-background pixel fraction > 0.02.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • peanutprotocol/peanut-ui#2196: Modifies the same ShareAssetD3.tsx share-asset rendering pipeline with visual/style changes that the current PR builds on.
  • peanutprotocol/peanut-ui#2302: Addresses the same blank share asset race condition via a readiness wait inside captureShareAsset.ts, directly complementing this PR's onReady gating approach.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly reflects the main changes: share-asset capture fixes, shadow rendering, and readiness gating.
Description check ✅ Passed The description is directly about the share-asset capture fixes and test coverage, matching the pull request changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

@github-actions

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 5843.8 → 5844.93 (+1.13)
Findings: 0 net (+29 new, -29 resolved)

🆕 New findings (29)

  • critical complexity — src/app/(mobile-ui)/dev/share-builder/page.tsx — CC 55, MI 67.17, SLOC 221
  • high complexity — src/components/Card/share-asset/ShareAssetD3.tsx — CC 36, MI 49.7, SLOC 229
  • high complexity — src/components/Card/share-asset/PixelatedCardFace.tsx — CC 30, MI 57.37, SLOC 157
  • high complexity — src/components/Card/share-asset/ShareAssetActions.tsx — CC 23, MI 49.98, SLOC 105
  • medium react-long-component — src/app/(mobile-ui)/dev/share-builder/page.tsx:29 — ShareBuilderPage is 423 lines — split it
  • medium high-mdd — src/app/(mobile-ui)/dev/share-builder/page.tsx:29 — ShareBuilderPage: MDD 192.5 (uses across many lines from declarations)
  • medium high-mdd — src/components/Card/BadgeSkipCelebration.tsx:61 — BadgeSkipCelebration: MDD 51.0 (uses across many lines from declarations)
  • medium high-mdd — src/components/Card/share-asset/ShareAssetD3.tsx:94 — ShareAssetD3: MDD 44.0 (uses across many lines from declarations)
  • medium high-mdd — src/components/Card/CardUnlockDrawer.tsx:33 — CardUnlockDrawer: MDD 39.8 (uses across many lines from declarations)
  • medium structural-dup — app/(mobile-ui)/dev/rejection-builder/page.tsx:156 — 38 duplicate lines / 192 tokens with app/(mobile-ui)/dev/share-builder/page.tsx:446
  • medium high-dlt — src/app/(mobile-ui)/dev/share-builder/page.tsx:29 — ShareBuilderPage: DLT 34 (calls 34 distinct functions — high context load)
  • medium high-mdd — src/components/Card/share-asset/ShareAssetD3.tsx:350 — HeroMessageEl: MDD 24.0 (uses across many lines from declarations)
  • medium hotspot — src/components/Card/share-asset/ShareAssetD3.tsx — 22 commits, +1223/-776 lines since 6 months ago
  • medium complexity — src/components/Card/BadgeSkipCelebration.tsx — CC 21, MI 59, SLOC 86
  • medium high-mdd — src/components/Card/share-asset/ShareAssetActions.tsx:79 — ShareAssetActions: MDD 21.2 (uses across many lines from declarations)
  • medium react-direct-dom — src/components/Card/share-asset/PixelatedCardFace.tsx:163 — direct DOM: document.createElement
  • medium react-direct-dom — src/components/Card/share-asset/PixelatedCardFace.tsx:180 — direct DOM: document.createElement
  • medium react-direct-dom — src/components/Card/share-asset/PixelatedCardFace.tsx:248 — direct DOM: document.createElement
  • medium nextjs-raw-img — src/components/Card/share-asset/PixelatedCardFace.tsx:103 — Use next/image
  • medium nextjs-raw-img — src/components/Card/share-asset/PixelatedCardFace.tsx:109 — Use next/image

…and 9 more.

✅ Resolved (29)

  • src/app/(mobile-ui)/dev/share-builder/page.tsx — CC 51, MI 68.07, SLOC 199
  • src/components/Card/share-asset/ShareAssetD3.tsx — CC 36, MI 49.87, SLOC 226
  • src/components/Card/share-asset/PixelatedCardFace.tsx — CC 30, MI 57.63, SLOC 154
  • src/app/(mobile-ui)/dev/share-builder/page.tsx:28 — ShareBuilderPage is 385 lines — split it
  • src/app/(mobile-ui)/dev/share-builder/page.tsx:28 — ShareBuilderPage: MDD 223.0 (uses across many lines from declarations)
  • src/components/Card/BadgeSkipCelebration.tsx:61 — BadgeSkipCelebration: MDD 50.8 (uses across many lines from declarations)
  • src/components/Card/share-asset/ShareAssetD3.tsx:94 — ShareAssetD3: MDD 42.4 (uses across many lines from declarations)
  • app/(mobile-ui)/dev/rejection-builder/page.tsx:156 — 38 duplicate lines / 192 tokens with app/(mobile-ui)/dev/share-builder/page.tsx:407
  • src/components/Card/CardUnlockDrawer.tsx:33 — CardUnlockDrawer: MDD 37.0 (uses across many lines from declarations)
  • src/components/Card/share-asset/ShareAssetD3.tsx:339 — HeroMessageEl: MDD 24.0 (uses across many lines from declarations)
  • src/hooks/useZeroDev.ts — 22 commits, +167/-140 lines since 6 months ago
  • src/components/Card/share-asset/ShareAssetActions.tsx — CC 21, MI 50.1, SLOC 105
  • src/components/Card/share-asset/ShareAssetActions.tsx:74 — ShareAssetActions: MDD 21.2 (uses across many lines from declarations)
  • src/components/Card/BadgeSkipCelebration.tsx — CC 20, MI 58.07, SLOC 81
  • src/components/Card/share-asset/PixelatedCardFace.tsx:145 — direct DOM: document.createElement
  • src/components/Card/share-asset/PixelatedCardFace.tsx:162 — direct DOM: document.createElement
  • src/components/Card/share-asset/PixelatedCardFace.tsx:230 — direct DOM: document.createElement
  • src/components/Card/share-asset/PixelatedCardFace.tsx:86 — Use next/image
  • src/components/Card/share-asset/PixelatedCardFace.tsx:92 — Use next/image
  • src/components/Card/share-asset/ShareAssetD3.tsx:315 — Use next/image

…and 9 more.

@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 1605 ran, 0 failed, 0 skipped, 24.9s

📊 Coverage (unit)

metric %
statements 54.5%
branches 37.0%
functions 42.2%
lines 54.4%
⏱ 10 slowest test cases
time test
3.8s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
0.5s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.4s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in updateUserById
0.3s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in updateUserById body
0.2s src/components/Card/__tests__/CardFace.test.tsx › shows the registered name when the card is revealed
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid 9-digit US account
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid UK IBAN with spaces
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid ETH address with surrounding spaces
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle too long for US account
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle invalid ETH address (invalid characters)
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

CI typecheck couldn't resolve 'sharp' (not a declared dependency). Decode +
sample the captured PNG in-page via an <img> + <canvas>.getImageData inside
page.evaluate instead — same not-empty assertion, zero new dependencies, so
the supply-chain min-release-age gate stays untouched.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/Card/share-asset/PixelatedCardFace.tsx (1)

291-302: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Ignore stale hand-raster completions before firing onReady.

Line 296 starts async work, but Line 302 always unblocks capture even if this node has already been replaced. After the builder rerolls or toggles animation, an older rasterImg() completion can flip assetReady back to true before the current card has appended its canvas. node.firstChild also doesn't stop duplicate raster jobs during rerenders before the first append. Guard the callback with an in-flight/still-connected check before appendChild()/onReady().

Suggested fix
 const PixelatedHand: FC<{ onReady?: () => void }> = ({ onReady }) => (
     <div
         ref={(node) => {
-            if (!node || node.firstChild) return
+            if (!node || node.firstChild || node.dataset.handRasterState === 'loading') return
+            node.dataset.handRasterState = 'loading'
             const handRatio = 560 / 471 // hand display w/h
             const rasterW = handRatio > 1 ? HAND_RASTER_PX : Math.max(1, Math.round(HAND_RASTER_PX * handRatio))
             const rasterH = handRatio > 1 ? Math.max(1, Math.round(HAND_RASTER_PX / handRatio)) : HAND_RASTER_PX
             rasterImg(ASSET_CARD_HAND, rasterW, rasterH, (canvas) => {
+                if (!node.isConnected || node.firstChild) return
                 canvas.style.width = '100%'
                 canvas.style.height = '100%'
                 canvas.style.imageRendering = 'pixelated'
                 node.appendChild(canvas)
+                node.dataset.handRasterState = 'ready'
                 // Card face is now painted — let capture surfaces unblock.
                 onReady?.()
             })
         }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Card/share-asset/PixelatedCardFace.tsx` around lines 291 -
302, The hand raster callback in PixelatedCardFace can still fire after the
original DOM node has been replaced, causing stale completions to call onReady
too early. Add a guard in the ref callback and rasterImg completion path to
ensure the node is still the current connected element before appending the
canvas or unblocking capture. Use the existing ref closure around node and the
PixelatedCardFace rasterImg callback to ignore outdated jobs and only call
onReady for the active card face.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/components/Card/share-asset/PixelatedCardFace.tsx`:
- Around line 291-302: The hand raster callback in PixelatedCardFace can still
fire after the original DOM node has been replaced, causing stale completions to
call onReady too early. Add a guard in the ref callback and rasterImg completion
path to ensure the node is still the current connected element before appending
the canvas or unblocking capture. Use the existing ref closure around node and
the PixelatedCardFace rasterImg callback to ignore outdated jobs and only call
onReady for the active card face.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4b9c7a6e-d5db-4cff-afa9-c5698e528359

📥 Commits

Reviewing files that changed from the base of the PR and between 2ec8b5d and b6e187d.

📒 Files selected for processing (8)
  • e2e/flows/share-asset-capture.spec.ts
  • src/app/(mobile-ui)/dev/share-builder/page.tsx
  • src/components/Card/BadgeSkipCelebration.tsx
  • src/components/Card/CardUnlockDrawer.tsx
  • src/components/Card/share-asset/PixelatedCardFace.tsx
  • src/components/Card/share-asset/ShareAssetActions.tsx
  • src/components/Card/share-asset/ShareAssetD3.tsx
  • src/components/Card/share-asset/shareAsset.types.ts

@Hugo0 Hugo0 merged commit 9de604b into main Jun 29, 2026
22 of 24 checks passed
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.

1 participant