From 7d295fc8b3c242e765a4865ac4d2a0bb05b66fa6 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Tue, 23 Jun 2026 20:13:14 +0000 Subject: [PATCH 01/52] feat(card): redesign launch share asset as a sticker collage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the postage-stamp layout with a force-directed sticker collage — the pixelated card sits centred, badges are placed by a repulsion-based solver that fills the field without heavy overlap, and a hero "I'M IN!" burst + a peanut.me/ pill frame it. The card's peanut logo and pixel hand stay uncovered via sticker-half-inflated keep-outs. Why: the launch "I got in" share moment needed a louder, more on-brand asset. The old stamp framing + EDITION/tier/points/stats chrome read as cluttered, and the prior ring layout crowded the edges at high badge counts. The look ships via component defaults, so existing surfaces (BadgeSkipCelebration, CardUnlockDrawer) pick it up with no caller changes. Also exposes /dev/share-builder as a dev-only public route (prod still 404s) for iterating on the asset. --- .../(mobile-ui)/dev/share-builder/page.tsx | 332 +++++----- .../Card/share-asset/PixelatedCardFace.tsx | 30 +- .../Card/share-asset/ShareAssetD3.tsx | 581 +++++++----------- .../__tests__/shareAssetLayout.test.ts | 84 +-- .../Card/share-asset/shareAsset.types.ts | 34 + .../Card/share-asset/shareAssetLayout.ts | 444 ++++++------- src/constants/routes.ts | 3 +- src/styles/globals.css | 63 -- 8 files changed, 676 insertions(+), 895 deletions(-) diff --git a/src/app/(mobile-ui)/dev/share-builder/page.tsx b/src/app/(mobile-ui)/dev/share-builder/page.tsx index 0e5123fe8..84df58b76 100644 --- a/src/app/(mobile-ui)/dev/share-builder/page.tsx +++ b/src/app/(mobile-ui)/dev/share-builder/page.tsx @@ -1,25 +1,26 @@ 'use client' /** - * /dev/share-builder — iterator for the D3 card-waitlist share asset. + * /dev/share-builder — iterator for the card share asset (sticker collage). * * Controls feed `` so we can stress-test edge cases: - * - 0 → 9 badges - * - usernames 2 → 20+ chars - * - missing / zero stats - * - tier 0 → 3 + * - 0 → all badges (sticker count drives the force-directed layout) + * - usernames 2 → 20+ chars (the @username pill auto-shrinks) * - * The "reroll seed" button forces a new layout for the same user (the - * component is otherwise deterministic from username). + * The asset is now a pure sticker collage — card in the middle, badges + * slapped around it, @username pill. There are no stats / tier / points / + * card-number inputs anymore; the layout is driven entirely by the badge + * set + username seed. "Reroll seed" forces a new force-directed layout for + * the same user (otherwise deterministic from username). */ import { useMemo, useState } from 'react' import NavHeader from '@/components/Global/NavHeader' import { Button } from '@/components/0_Bruddle/Button' import ShareAssetD3 from '@/components/Card/share-asset/ShareAssetD3' +import type { HeroVariant, UsernameBg } from '@/components/Card/share-asset/shareAsset.types' import { BADGE_CODES, getBadgeDisplayName } from '@/components/Badges/badge.utils' import { CANVAS_W, CANVAS_H } from '@/components/Card/share-asset/shareAssetLayout' -import type { TierLevel } from '@/components/Card/share-asset/shareAsset.types' const ALL_CODES = BADGE_CODES @@ -36,38 +37,42 @@ export default function ShareBuilderPage() { 'SUPPORT_SURVIVOR', ]) ) - const [tier, setTier] = useState(3) - const [points, setPoints] = useState(1247) - const [last4, setLast4] = useState('5695') - const [joinedAt, setJoinedAt] = useState('2025-10-12') - const [movedUsd, setMovedUsd] = useState(4287) - const [txns, setTxns] = useState(142) - const [invited, setInvited] = useState(12) const [animate, setAnimate] = useState(true) const [seedNonce, setSeedNonce] = useState(0) const [previewScale, setPreviewScale] = useState(0.8) - // Derived props — memoized so ShareAssetD3's `useMemo(...,[badges,stats])` doesn't - // re-run on every parent render (it would, otherwise, because object/array literals + // ─── Hero "I got in" message sticker ───────────────────────────────── + const [heroVariant, setHeroVariant] = useState('burst') + const [heroText, setHeroText] = useState("I'M IN!") + const [heroScale, setHeroScale] = useState(1.15) + const [heroTilt, setHeroTilt] = useState(5) + const heroMessage = + heroVariant === 'none' ? null : { text: heroText, variant: heroVariant, scale: heroScale, tilt: heroTilt } + + // ─── Username pill colour + typography ─────────────────────────────── + const [unameBg, setUnameBg] = useState('white') + const [unamePrefix, setUnamePrefix] = useState(0.5) + const [unameScale, setUnameScale] = useState(1) + const [unameTracking, setUnameTracking] = useState(0) + const usernameStyle = { + bg: unameBg, + prefixRatio: unamePrefix, + scale: unameScale, + letterSpacing: unameTracking, + } + + // Derived props — memoized so ShareAssetD3's `useMemo(...,[badges])` doesn't + // re-run on every parent render (it would, otherwise, because array literals // are fresh references each render). const badgesArray = useMemo( () => [...selectedBadges].map((code, i) => ({ code, - // Stagger earnedAt so sorting is stable + variety in stamp year denominations. + // Stagger earnedAt so the most-recent-first sort is stable. earnedAt: new Date(2024 + (i % 3), i % 12, 1).toISOString(), })), [selectedBadges] ) - const statsProp = useMemo( - () => ({ - joinedAt: joinedAt || null, - totalMovedUsd: movedUsd, - totalTxns: txns, - invitedCount: invited, - }), - [joinedAt, movedUsd, txns, invited] - ) const toggleBadge = (code: string) => { setSelectedBadges((prev) => { @@ -87,6 +92,64 @@ export default function ShareBuilderPage() {
{/* ─── LEFT: Controls ──────────────────────────────────── */}
-
- -
- {[0, 1, 2, 3].map((t) => ( - - ))} -
-
- - setPoints(Number(e.target.value))} - className="w-full" - /> - -
- -
- - setJoinedAt(e.target.value)} - className="custom-input" - /> - - - setMovedUsd(Number(e.target.value))} - className="w-full" - /> - - - setTxns(Number(e.target.value))} - className="w-full" - /> - - - setInvited(Number(e.target.value))} - className="w-full" - /> - -
-
-
+
setUsername('me')}>2-char user setUsername('twelvechars1')}>12 chars (max) setUsername('thisistwentyplus_chars')}>20+ chars - { - setMovedUsd(0) - setTxns(0) - setInvited(0) - setJoinedAt('') - }} - > - Zero stats - - { - setMovedUsd(0) - setTxns(0) - setInvited(0) - setJoinedAt('') - setSelectedBadges(new Set()) - setTier(0) - setPoints(0) - }} - > - Brand-new user - - { - setMovedUsd(1_500_000) - setTxns(9999) - setInvited(50) - setPoints(9999) - setTier(3) - setSelectedBadges(new Set(ALL_CODES)) - }} - > - Whale user - + setUsername('kkonrad')}>reset
- {username.length > 12 && ( -

- ⚠️ Username > 12 chars · production caps at 12. The asset shrinks the @username pill - defensively, but check the input gate in your caller. -

- )}
@@ -334,11 +355,9 @@ export default function ShareBuilderPage() { key={`${seedNonce}-${animate}`} username={username || 'anon'} badges={badgesArray} - stats={statsProp} - tier={tier} - pointsBalance={points} - cardLast4={last4} seedOverride={seedOverride} + heroMessage={heroMessage} + usernameStyle={usernameStyle} animate={animate} /> @@ -352,11 +371,10 @@ export default function ShareBuilderPage() { { username, badges: badgesArray.map((b) => b.code), - stats: statsProp, - tier, - pointsBalance: points, - cardLast4: last4, seedOverride, + heroMessage, + usernameStyle, + animate, }, null, 2 diff --git a/src/components/Card/share-asset/PixelatedCardFace.tsx b/src/components/Card/share-asset/PixelatedCardFace.tsx index ccba991ef..d699f842e 100644 --- a/src/components/Card/share-asset/PixelatedCardFace.tsx +++ b/src/components/Card/share-asset/PixelatedCardFace.tsx @@ -54,9 +54,18 @@ export interface PixelatedCardFaceProps { * through the same canvas-rasterisation pipeline as the hand so the * whole card reads as chunky pixels, not a mix of pixels + blur. */ blurAll?: boolean + /** When true, omit the Visa brand mark entirely. The share asset can't + * display the Visa wordmark for compliance reasons (it renders crisp + * there since the share asset is the one surface that doesn't `blurAll`). */ + hideVisa?: boolean } -export const PixelatedCardFace: FC = ({ className, style, blurAll = false }) => ( +export const PixelatedCardFace: FC = ({ + className, + style, + blurAll = false, + hideVisa = false, +}) => (
= ({ className, style ) : ( )} - {blurAll ? ( - - ) : ( - Visa - )} + {!hideVisa && + (blurAll ? ( + + ) : ( + Visa + ))}
{/* Bottom: card number — same `•••• ????` pattern in both modes. diff --git a/src/components/Card/share-asset/ShareAssetD3.tsx b/src/components/Card/share-asset/ShareAssetD3.tsx index 80949e537..7c56bdfac 100644 --- a/src/components/Card/share-asset/ShareAssetD3.tsx +++ b/src/components/Card/share-asset/ShareAssetD3.tsx @@ -2,17 +2,15 @@ * — the in-app share asset that users can post or save * after they get their Peanut card. * - * Asset is 1200×900 (4:3 postage-stamp proportions — see CANVAS_W / - * CANVAS_H in shareAssetLayout.ts). It's NOT a Twitter card image: the - * X intent (see share.utils.ts) is text-only and doesn't include a URL, - * so there's no OG/twitter:image scrape path. Capture is via - * captureShareAsset.ts (html-to-image at native 1200×900, attached to - * navigator.share or downloaded by the user). + * Asset is 1200×900 (4:3 — see CANVAS_W / CANVAS_H in shareAssetLayout.ts). + * It's NOT a Twitter card image: the X intent (see share.utils.ts) is + * text-only and doesn't include a URL, so there's no OG/twitter:image + * scrape path. Capture is via captureShareAsset.ts (html-to-image at + * native 1200×900, attached to navigator.share or downloaded). * - * Type hierarchy is tuned for readability at the captured PNG's typical - * downscale on social timelines: every meaningful text is ≥22px native - * (~7px at 3.4× downscale, bold-readable), with the username pill + - * EDITION header far larger. + * Composition is a sticker collage: the pixelated card sits in the middle + * and the user's badges are slapped around it as big raw stickers. The + * only text is the @username pill. */ 'use client' @@ -26,96 +24,92 @@ import { CARD_TOP, CARD_ROTATION_DEG, placeStamps, - placeDecorations, - buildStatColumns, usernameFontSize, type StampPlacement, - type DecorationPlacement, + type KeepoutEllipse, } from './shareAssetLayout' -import type { ShareAssetD3Props, TierLevel } from './shareAsset.types' -import { TIER_0_BADGE, TIER_1_BADGE, TIER_2_BADGE, TIER_3_BADGE } from '@/assets/badges' -import { PEANUTMAN_HOLDING_BEER } from '@/assets/mascot' -import { STAR_STRAIGHT_ICON } from '@/assets/icons' -import { HandPeace, HandThumbsUp, HandThumbsUpV2, Eyes, Cloud, Star } from '@/assets/illustrations' +import type { ShareAssetD3Props, HeroMessage } from './shareAsset.types' import { PixelatedCardFace } from './PixelatedCardFace' -const ASSET_STAR = STAR_STRAIGHT_ICON.src -const ASSET_STAR_ALT = Star.src -const ASSET_HAND_THUMBS = HandThumbsUp.src -const ASSET_HAND_THUMBS_V2 = HandThumbsUpV2.src -const ASSET_HAND_PEACE = HandPeace.src -const ASSET_EYES = Eyes.src -const ASSET_CLOUD = Cloud.src -// The peanut character art style is intentionally legless — the body -// tapers to a rounded point. Rather than dropping the character, we -// place him BEHIND the card with his bottom inside the card's bbox so -// only his head + arms peek out above the card edge. Of the available -// poses, peanut-holding-beer is the most expressive (both arms visible, -// holding a beer, peace sign) and reads cleanly at small sizes. -const ASSET_PEANUT_CHAR = PEANUTMAN_HOLDING_BEER.src - -const TIER_SVG: Record = { - 0: TIER_0_BADGE, - 1: TIER_1_BADGE, - 2: TIER_2_BADGE, - 3: TIER_3_BADGE, -} - -const TIER_LABEL: Record = { - 0: 'TIER 0', - 1: 'TIER 1', - 2: 'TIER 2', - 3: 'TIER 3', -} - -const DECO_ASSET: Record = { - star: ASSET_STAR, - starAlt: ASSET_STAR_ALT, - thumbsUp: ASSET_HAND_THUMBS, - thumbsUpV2: ASSET_HAND_THUMBS_V2, - peace: ASSET_HAND_PEACE, - eyes: ASSET_EYES, - cloud: ASSET_CLOUD, - peanutChar: ASSET_PEANUT_CHAR, -} - // Peanut blue — the brand section colour reused from the prod landing page // (LP `businessBgColor` in dropLink.tsx + the global `--background-color`). -// Replaces the off-brandbook lavender so the asset pops on a timeline. const ASSET_BG = '#90A8ED' const ANIM_CARD_DELAY = 100 const ANIM_STAMP_BASE_DELAY = 600 -const ANIM_STAMP_STAGGER = 250 -const ANIM_TIER_DELAY = 1500 +const ANIM_STAMP_STAGGER = 200 +const ANIM_HERO_DELAY = 350 const ANIM_ATTRIBUTION_DELAY = 1700 -const ANIM_EARNED_DELAY = 1900 + +// Hero message sits centred near the top of the canvas. +const HERO_TOP = 26 +const HERO_CX = CANVAS_W / 2 + +/** Nominal pixel footprint of the hero sticker, used both to render it and to + * reserve a keep-out so badges don't cover it. Width grows with the copy. */ +function heroGeometry(msg: HeroMessage): { w: number; h: number; fontSize: number } { + const scale = msg.scale ?? 1 + const len = Math.max(msg.text.length, 4) + if (msg.variant === 'burst') { + const fontSize = 58 * scale + const w = Math.max(320, len * fontSize * 0.58 + 180) + const h = Math.max(240, fontSize + 150) * scale + return { w, h, fontSize } + } + // pill + banner are single-line labels + const fontSize = 60 * scale + const h = fontSize + 40 + const w = Math.max(240, len * fontSize * 0.6 + 96) + return { w, h, fontSize } +} + +const USERNAME_BG: Record['bg'], string> = { + white: '#FFFFFF', + pink: '#FF90E8', + blue: '#6E8BEF', +} + +// The shipped look. Callers that omit these props (the real card-unlock share +// surfaces) get this; the /dev/share-builder passes its own to iterate. +const DEFAULT_HERO: HeroMessage = { variant: 'burst', text: "I'M IN!", scale: 1.15, tilt: 5 } +const DEFAULT_USERNAME_STYLE: Required> = { + bg: 'white', + prefixRatio: 0.5, + scale: 1, + letterSpacing: 0, +} const ShareAssetD3: FC = ({ username, badges, - stats, - tier = 0, - pointsBalance = 0, cardLast4, seedOverride, + heroMessage, + usernameStyle, animate = true, }) => { const safeUsername = (username || '').trim() || 'anon' const safeLast4 = (cardLast4 || '').trim().padStart(4, '•').slice(-4) || '5695' - const { stamps, decorations, statCols } = useMemo(() => { - const rng = new SeededRandom(seedOverride ?? safeUsername) - return { - stamps: placeStamps(badges, rng), - decorations: placeDecorations(rng), - statCols: buildStatColumns(stats), - } - }, [seedOverride, safeUsername, badges, stats]) + // `undefined` (real callers) → the shipped default hero; `null` (builder + // "none") → no hero. + const resolvedHero = heroMessage === undefined ? DEFAULT_HERO : heroMessage + const hero = resolvedHero && resolvedHero.text.trim() ? resolvedHero : null + const heroGeo = useMemo(() => (hero ? heroGeometry(hero) : null), [hero?.text, hero?.variant, hero?.scale]) + + const stickers = useMemo(() => { + const extraKeepouts: KeepoutEllipse[] = heroGeo + ? [{ cx: HERO_CX, cy: HERO_TOP + heroGeo.h / 2, rx: heroGeo.w / 2, ry: heroGeo.h / 2 }] + : [] + return placeStamps(badges, new SeededRandom(seedOverride ?? safeUsername), extraKeepouts) + }, [seedOverride, safeUsername, badges, heroGeo]) - // "Truly new" users (tier 0 + 0 pts) hide the whole tier block — "TIER 0 / 0" - // reads as "you have nothing", wrong vibe for a celebration asset. - const showTierBlock = tier > 0 || pointsBalance > 0 + // Username pill: "peanut.me/" — the prefix is rendered much smaller + // than the handle. Colour + typography come from usernameStyle. + const uHandleSize = usernameFontSize(safeUsername) * (usernameStyle?.scale ?? DEFAULT_USERNAME_STYLE.scale) + const uPrefixSize = uHandleSize * (usernameStyle?.prefixRatio ?? DEFAULT_USERNAME_STYLE.prefixRatio) + const uTracking = usernameStyle?.letterSpacing ?? DEFAULT_USERNAME_STYLE.letterSpacing + const uBg = USERNAME_BG[usernameStyle?.bg ?? DEFAULT_USERNAME_STYLE.bg] return (
= ({ transform: translateY(0); } } - @keyframes sparkle { - 0% { - opacity: 0; - transform: scale(0.4) rotate(0deg); - } - 100% { - opacity: var(--rest-opacity, 0.95); - transform: var(--rest-transform); - } - } `} {/* ─── Background pattern (faint pink polka — texture, no content) ─── */} @@ -184,116 +168,7 @@ const ShareAssetD3: FC = ({ }} /> - {/* ─── Decorations (stars + thumbs-up + peanut chars) ─── */} - {decorations.map((deco, i) => ( - - ))} - - {/* ─── EDITION header (top-left) — consolidated single banner. - Was 2 lines + a rotated vertical spine; now one tighter - line that reads cleanly at thumbnail scale and stops - fighting the EARNED stamp on the right. */} -
- EDITION · 01 - - PEANUT CARD ARRIVES - -
- - {/* ─── Tier badge + stats anchor (left mid) ─── */} - {(showTierBlock || statCols.length > 0) && ( -
- {showTierBlock && ( -
- -
- - {TIER_LABEL[tier]} · PEANUT PTS - - - {pointsBalance.toLocaleString('en-US')} - -
-
- )} - {/* Stats strip — renders only the cols present */} - {statCols.length > 0 && ( -
-
- {statCols.map((col, i) => ( - - {i > 0 && ( - - · - - )} - - - {col.label} - - - {col.value} - - - - ))} -
-
- )} -
- )} - - {/* ─── Stamps BEHIND the card (z-index 2) ─── */} - {stamps - .filter((s) => s.behind) - .map((s, i) => ( - - ))} - - {/* ─── The card (z-index 3) ─── */} + {/* ─── The card (z-index 3) — sits in the middle of the collage ─── */}
= ({ : 'none', }} > - +
- {/* ─── Stamps IN FRONT (z-index 4) ─── */} - {stamps - .filter((s) => !s.behind) - .map((s, i, frontList) => { - const behindCount = stamps.length - frontList.length - return ( - - ) - })} - - {/* ─── EARNED ✓ rubber stamp (top-right corner, tasteful) ─── */} -
-
- EARNED ✓ -
-
+ {/* ─── Stickers (z-index 4) — raw badge art collaged ON TOP of the + card. No frames, no chrome: the badge SVGs already carry a + white sticker border + thick outline, so they read as + peel-off stickers slapped over the card. ─── */} + {stickers.map((s, i) => ( + + ))} - {/* ─── @username pill — the sharer's own handle, displayed for - pride. No "who invited you?" caption: this is the asset's - "this is ME" anchor, not a referral nudge to the viewer. ─── */} + {/* ─── @username pill — the sharer's own handle, the asset's + "this is ME" anchor. Sits below the stickers (z-index 4) so a + sticker that lands over it reads as slapped on top; the pill's + repulsion keep-out keeps it mostly clear regardless. ─── */}
- @{safeUsername} + peanut.me/ + + {safeUsername} +
+ + {/* ─── Hero "I got in" message sticker (top, z-index 5 — above the + collage). Its keep-out (computed above) keeps badges clear. ─── */} + {hero && heroGeo && ( +
+ +
+ )}
) } // ─── Sub-components ──────────────────────────────────────────────────── -const HeroCaps: FC<{ children: React.ReactNode; style?: React.CSSProperties; className?: string }> = ({ - children, - style, - className, -}) => ( -
- {children} -
-) - -interface StampElProps { - stamp: StampPlacement +interface StickerElProps { + sticker: StampPlacement animate: boolean delay: number } -const StampEl: FC = ({ stamp, animate, delay }) => { - const restTransform = `rotate(${stamp.rotation}deg)` - return ( -
- {stamp.withTape && ( -
- )} - PEANUT - {/* Year denomination — dynamic, derived from badge.earnedAt at - layout time (see placeStamps in shareAssetLayout.ts). */} - {stamp.badge.year && {stamp.badge.year}} - {/* Badge icon fills the stamp. Caption row was dropped (Hugo: - "remove the colored subtitle from the stamps, just the - badge svg, a bit larger") — icon now uses the full - stamp interior below the issuer/year row. */} -
- -
-
- ) -} - -const DecorationEl: FC<{ deco: DecorationPlacement; animate: boolean; delay: number }> = ({ deco, animate, delay }) => { - const src = DECO_ASSET[deco.kind] - const restTransform = `rotate(${deco.rotation}deg)` - const opacity = deco.kind === 'star' ? 0.85 : 0.95 +// Raw badge sticker — just the SVG, rotated, with a hard offset shadow so +// it lifts off the card like a real peel-off sticker. No frame, no issuer +// text, no year: the badge art already has its own white sticker border. +const StickerEl: FC = ({ sticker, animate, delay }) => { + const restTransform = `rotate(${sticker.rotation}deg)` return ( ) } +// Hero "I got in" message — three sticker treatments. All share the thick +// black outline + hard offset shadow of the collage so they read as one more +// (bigger) sticker slapped on top. +const HeroMessageEl: FC<{ hero: HeroMessage; geo: { w: number; h: number; fontSize: number } }> = ({ hero, geo }) => { + const { text, variant } = hero + const { w, h, fontSize } = geo + // Tilt: explicit override, else a small per-variant lean. + const tilt = hero.tilt ?? (variant === 'banner' ? -2 : variant === 'pill' ? -3 : -4) + const rot = `rotate(${tilt}deg)` + + if (variant === 'pill') { + return ( + + {text} + + ) + } + + if (variant === 'banner') { + return ( + + {text} + + ) + } + + // burst — a yellow seal/starburst behind the text + const points = burstSealPoints(w / 2, h / 2, 16, 0.6) + return ( +
+ + + + + {text} + +
+ ) +} + +/** Build an N-spike seal/starburst polygon centred on (0,0), to be translated + * to the sticker centre. Alternates outer (rx,ry) and inner radii. */ +function burstSealPoints(rx: number, ry: number, spikes: number, innerRatio: number): string { + const pts: string[] = [] + for (let i = 0; i < spikes * 2; i++) { + const angle = (i * Math.PI) / spikes - Math.PI / 2 + const r = i % 2 === 0 ? 1 : innerRatio + pts.push(`${(Math.cos(angle) * rx * r).toFixed(1)},${(Math.sin(angle) * ry * r).toFixed(1)}`) + } + return pts.join(' ') +} + export default ShareAssetD3 diff --git a/src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts b/src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts index 6d49d3628..0ebd2e2ed 100644 --- a/src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts +++ b/src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts @@ -15,7 +15,6 @@ import { CARD_LEFT, CARD_TOP, placeStamps, - placeDecorations, buildStatColumns, usernameFontSize, } from '../shareAssetLayout' @@ -87,17 +86,17 @@ describe('placeStamps', () => { expect(single[0].width).toBeGreaterThan(six[0].width) }) - it('keeps stamps within canvas bounds (no clipping past edges)', () => { + it('keeps stickers within canvas bounds (bounded off-edge peek)', () => { const badges = Array.from({ length: 6 }, (_, i) => badge(`B${i}`)) const placed = placeStamps(badges, rng()) for (const s of placed) { - // Allow stamps to overlap card edges (collage layout intentionally - // peeks/overlaps) but every stamp's top-left anchor + its size - // should land within the canvas with generous tolerance. - expect(s.top).toBeGreaterThanOrEqual(-20) - expect(s.left).toBeGreaterThanOrEqual(-20) - expect(s.top + s.height).toBeLessThanOrEqual(CANVAS_H + 20) - expect(s.left + s.width).toBeLessThanOrEqual(CANVAS_W + 20) + // Stickers intentionally overlap the card and may peek off-edge, + // but only within a bounded tolerance — half-off-canvas was the + // regression Hugo flagged in QA. + expect(s.top).toBeGreaterThanOrEqual(-40) + expect(s.left).toBeGreaterThanOrEqual(-40) + expect(s.top + s.height).toBeLessThanOrEqual(CANVAS_H + 40) + expect(s.left + s.width).toBeLessThanOrEqual(CANVAS_W + 40) } }) @@ -127,43 +126,31 @@ describe('placeStamps', () => { expect(placed[0].badge.iconUrl).toBeTruthy() }) - // Strict non-overlap invariant for the "unique-slot" range (1..6 - // unique slots in the table). For every count 1..6 and across many - // seeds, no two placed stamps' rotation-aware bboxes may touch. - // - // We use the diagonal sqrt(w² + h²) as a worst-case radius from the - // stamp's center. Two stamps "overlap" if the distance between their - // centers is less than the sum of their radii. This is conservative - // (treats the rotated rect as a circumscribing circle) — if it - // passes, the actual rotated rects definitely don't overlap. - // - // For counts > 6 we deliberately stack (Hugo: "just stack them") so - // the invariant does not apply; the layout still stays within canvas - // (separate test below). - it('stamps never overlap for counts 1..6 (any seed)', () => { - const seeds = ['kkonrad', 'hugo', 'asfsfsf', 'a', 'longusername', '0', 'seed-42', '🥜'] - for (let n = 1; n <= 6; n++) { + // Light overlap is fine for the collage, but the blue-noise placer must + // never let two stickers pile up. Across the realistic range (1..10) and + // many seeds, every pair of centres must stay at least this fraction of + // the sticker size apart — i.e. no "heavy" overlap. (At absurd counts the + // ring saturates and this loosens; that's the stress regime, not tested.) + it('never places two stickers in heavy overlap (counts 1..10)', () => { + const seeds = ['kkonrad', 'hugo', 'asfsfsf', 'a', 'longusername', '0', 'seed-42', 'zzz', 'mara', '🥜'] + const MIN_CENTER_GAP = 0.4 // × size; below this is a heavy pile-up + for (let n = 2; n <= 10; n++) { const badges = Array.from({ length: n }, (_, i) => badge(`B${i}`)) for (const seed of seeds) { const placed = placeStamps(badges, new SeededRandom(seed)) + const size = placed[0].width for (let i = 0; i < placed.length; i++) { for (let j = i + 1; j < placed.length; j++) { const a = placed[i] const b = placed[j] - const cxA = a.left + a.width / 2 - const cyA = a.top + a.height / 2 - const cxB = b.left + b.width / 2 - const cyB = b.top + b.height / 2 - const dist = Math.hypot(cxA - cxB, cyA - cyB) - const radA = Math.hypot(a.width, a.height) / 2 - const radB = Math.hypot(b.width, b.height) / 2 - const required = radA + radB - if (dist < required) { + const dist = Math.hypot( + a.left + a.width / 2 - (b.left + b.width / 2), + a.top + a.height / 2 - (b.top + b.height / 2) + ) + if (dist < MIN_CENTER_GAP * size) { throw new Error( - `Stamp overlap at count=${n} seed="${seed}": ` + - `slots ${i} (${a.badge.code} @ ${a.left.toFixed(0)},${a.top.toFixed(0)}) ` + - `and ${j} (${b.badge.code} @ ${b.left.toFixed(0)},${b.top.toFixed(0)}) ` + - `— distance ${dist.toFixed(0)} < required ${required.toFixed(0)}` + `Heavy overlap at count=${n} seed="${seed}": centres ${dist.toFixed(0)}px apart ` + + `(< ${(MIN_CENTER_GAP * size).toFixed(0)} = ${MIN_CENTER_GAP}×${size})` ) } } @@ -172,13 +159,13 @@ describe('placeStamps', () => { } }) - // For any count (including 15+, which stacks), every stamp's axis- + // For any count (including 15+, which stacks), every sticker's axis- // aligned bbox must fit within the canvas with at most a small // overhang tolerance — half-outside-the-canvas was the regression // Hugo flagged in QA. - it('every stamp stays within canvas at any count', () => { + it('every sticker stays within canvas at any count', () => { const seeds = ['kkonrad', 'hugo', 'asfsfsf', 'longusername', 'seed-42'] - const overhang = 20 // peeking off-edge is part of the design, but only this much + const overhang = 40 // peeking off-edge is part of the design, but only this much for (let n = 1; n <= 15; n++) { const badges = Array.from({ length: n }, (_, i) => badge(`B${i}`)) for (const seed of seeds) { @@ -200,21 +187,6 @@ describe('placeStamps', () => { }) }) -describe('placeDecorations', () => { - it('returns at least one star and one character', () => { - const placed = placeDecorations(new SeededRandom('kkonrad')) - const kinds = new Set(placed.map((d) => d.kind)) - expect(kinds.has('star')).toBe(true) - expect(placed.some((d) => d.kind !== 'star')).toBe(true) - }) - - it('is deterministic per seed', () => { - const a = placeDecorations(new SeededRandom('kkonrad')) - const b = placeDecorations(new SeededRandom('kkonrad')) - expect(a).toEqual(b) - }) -}) - describe('buildStatColumns', () => { it('returns empty array when all stats are missing', () => { expect(buildStatColumns(undefined)).toEqual([]) diff --git a/src/components/Card/share-asset/shareAsset.types.ts b/src/components/Card/share-asset/shareAsset.types.ts index 7f16f8025..1742e4b62 100644 --- a/src/components/Card/share-asset/shareAsset.types.ts +++ b/src/components/Card/share-asset/shareAsset.types.ts @@ -28,6 +28,34 @@ export interface ShareAssetStats { export type TierLevel = 0 | 1 | 2 | 3 +/** Visual treatment for the hero "I got in" message sticker at the top. */ +export type HeroVariant = 'burst' | 'pill' | 'banner' + +export interface HeroMessage { + /** The headline copy, e.g. "I'M IN" or "shhhh, i'm in". */ + text: string + /** Sticker shape/treatment. */ + variant: HeroVariant + /** Size multiplier (1 = default). The builder exposes this as a slider. */ + scale?: number + /** Tilt in degrees (clockwise). Defaults to a small per-variant lean. */ + tilt?: number +} + +/** Background colour for the username pill. */ +export type UsernameBg = 'white' | 'pink' | 'blue' + +/** Typography + colour controls for the `peanut.me/` pill. */ +export interface UsernameStyle { + bg: UsernameBg + /** "peanut.me/" prefix size as a fraction of the handle font size (~0.2–0.7). */ + prefixRatio?: number + /** Handle font-size multiplier (1 = the auto-fit default). */ + scale?: number + /** Handle letter spacing, in em. */ + letterSpacing?: number +} + export interface ShareAssetD3Props { /** Lowercase username, max 12 chars per current peanut constraint. */ username: string @@ -47,6 +75,12 @@ export interface ShareAssetD3Props { /** Last 4 of card PAN — rendered on the card face. */ cardLast4?: string + /** Optional hero "I got in" message sticker at the top. Null/omitted = none. */ + heroMessage?: HeroMessage | null + + /** Username pill colour + typography. Defaults to pink with auto-fit sizing. */ + usernameStyle?: UsernameStyle + /** * Override the username-derived seed. Used by the /dev/share-builder * "reroll" button to dice-roll positions without changing user input. diff --git a/src/components/Card/share-asset/shareAssetLayout.ts b/src/components/Card/share-asset/shareAssetLayout.ts index 4e88b61cf..d7afe87c7 100644 --- a/src/components/Card/share-asset/shareAssetLayout.ts +++ b/src/components/Card/share-asset/shareAssetLayout.ts @@ -33,94 +33,110 @@ export const CARD_LEFT = Math.round((CANVAS_W - CARD_W) / 2) // 220 — centred export const CARD_TOP = Math.round((CANVAS_H - CARD_H) / 2) // 210 — centred export const CARD_ROTATION_DEG = -8 -// ─── Stamp positions ──────────────────────────────────────────────────── -// Slots that stamps can land in, in priority order. Each carries a -// "behind" flag — true = z-index BELOW the card (stamp peeks out from -// behind), false = z-index ABOVE the card (stamp lands on top, overlapping). -// PRNG jiggles within `jitter` bounds. +// ─── Sticker placement (force-directed) ───────────────────────────────── +// Stickers fill the blue field AROUND the card using a small position-based +// relaxation (a constraint solver, not a fixed ring). Three forces settle +// them into the 2D background instead of crowding a single ellipse: +// • a strong keep-clear ellipse over the card centre repels stickers off +// the card's middle, so the pixel logo stays readable; +// • every sticker repels every other, so they spread out and barely +// overlap, however many there are; +// • a soft inward margin pulls them off the extreme canvas edge. +// They still render on top of the card (z-index above it) and may cover its +// outer edges; only its centre and the bottom-right @username pill are kept +// clear. +const FIELD_CX = CANVAS_W / 2 // 600 +const FIELD_CY = CANVAS_H / 2 // 450 -export interface StampSlot { - /** Anchor in canvas coords (top-left of stamp). */ - top: number - left: number - /** ±px wiggle applied via PRNG. */ - jitterXY: number - /** Base rotation in deg. */ - rotation: number - /** ±deg rotation wiggle. */ - jitterRot: number - /** Behind the card (peeks out) or in front (overlaps card edges). */ - behind: boolean - /** Optional: tape strip pinning the stamp. */ - withTape?: boolean -} +// Seed ellipse — only used to scatter the initial positions around the card +// before relaxation. The actual "don't cover this" repulsion lives in +// CARD_KEEPOUTS (the centred hand keep-out doubles as the card-centre guard). +const KEEP_A = 300 +const KEEP_B = 205 + +// Specific card graphics that must never be covered. Unlike the soft centre +// ellipse above, each of these is inflated by the sticker's half-size in the +// solver, so no sticker overlaps the mark at all (not just its centre). +// Positions are canvas coords: the card renders at (220,210) sized 760×479, +// rotated −8° about its centre (600,450) — these are the peanut logo +// (card-local ~54,50) and the pixel hand (card-local ~460,200) mapped through +// that transform. +const CARD_KEEPOUTS: readonly { cx: number; cy: number; rx: number; ry: number }[] = [ + { cx: 251, cy: 307, rx: 40, ry: 40 }, // peanut logo (top-left of the card) + { cx: 660, cy: 405, rx: 142, ry: 100 }, // pixelated hand (centre, leaning up-right) +] -// Slots are positioned to never overlap each other AND never overlap -// fixed chrome on the 1200×900 canvas: -// - EARNED rubber stamp: x[920..1200], y[20..200] -// - EDITION + tier block: x[20..360], y[20..460] -// - @username pill + tagline: x[400..800], y[770..900] -// - Card bbox (axis-aligned): x[264..884], y[254..645] -// -// Stamps with `behind:true` may sit ON TOP of the card bbox — they're -// rendered z-index BELOW the card and peek out from behind. Their visible -// portion must still land mostly OUTSIDE the card so the icon reads. -// -// Non-overlap is enforced by shareAssetLayout.test.ts (`stamps never -// overlap`) — DO NOT edit positions without re-running that test. -// -// The catalogue is intentionally larger than 6 (the prior cap). For -// N ≥ 7 we wrap with a per-cycle offset so stacked stamps look like a -// pile rather than perfectly aligned duplicates. -const STAMP_SLOTS: readonly StampSlot[] = [ - // 1. HERO behind, top-center — peeks down from above the card. - { top: 140, left: 504, jitterXY: 12, rotation: -12, jitterRot: 5, behind: true, withTape: true }, - // 2. Front bottom-left. - { top: 720, left: 80, jitterXY: 10, rotation: -10, jitterRot: 4, behind: false }, - // 3. Behind upper-right of card (clear of EARNED). - { top: 220, left: 940, jitterXY: 10, rotation: 14, jitterRot: 5, behind: true }, - // 4. Front mid-left — peeks at card's left edge. - { top: 440, left: 60, jitterXY: 10, rotation: -16, jitterRot: 4, behind: false }, - // 5. Behind right-of-card, lower half. - { top: 480, left: 940, jitterXY: 10, rotation: 18, jitterRot: 5, behind: true }, - // 6. Front bottom-right (opposite slot 2 across the pill). - { top: 720, left: 900, jitterXY: 10, rotation: 8, jitterRot: 4, behind: false }, -] as const +// Soft inward margin from the canvas edge — pulls stickers off the extreme +// edge into the background; the hard clamp keeps the bbox within OVERHANG. +const EDGE_MARGIN = 32 +const STICKER_OVERHANG = 24 + +// Repulsion keep-out for the @username pill (bottom-right corner). Its rect +// extends to the bottom-right canvas corner; PILL_PAD adds a margin so the +// repulsion leaves a gap, not just a touch. Any sticker pushed inside is +// shoved back out each relaxation pass — the pill repels like everything else. +const PILL_KEEPOUT = { x0: 690, y0: 712 } as const +const PILL_PAD = 26 + +// Relaxation iterations + how close two sticker centres may sit (× combined +// radii). 1.0 = just touching; below 1.0 allows a bit of overlap while the +// solver still actively repels them apart each pass. +const RELAX_ITERS = 120 +const STICKER_GAP = 0.82 export interface StampPlacement { - badge: { code: string; iconUrl: string; year?: string } + badge: { code: string; iconUrl: string } top: number left: number rotation: number - behind: boolean - withTape: boolean - /** Size: shrinks as badge count grows (so 6 badges aren't huge). */ + /** Size: shrinks as badge count grows (so 6 badges aren't huge). + * Stickers are square — width === height. */ width: number height: number } -// Inversely scale stamp size with count: 1 stamp gets a hero treatment, -// 10+ stamps shrink to fit. Beyond the table, clamp to the smallest size. -// -// Heights are bounded so bottom slots (top≈720) + max-jitter (10) + height -// stay within the canvas + a 20px overhang tolerance (900 + 20 - 730 = 190). -// Low counts (1–3, the common case) get a Twitter-legibility bump; counts -// 4–10 stay near original since the collage crowds and the non-overlap -// invariant (circumscribing-circle metric + jitter) leaves little headroom. -const STAMP_SIZE_BY_COUNT: ReadonlyArray = [ - [248, 290], // 1 — only slot 1 (hero, top=140) used; canvas-safe. - [182, 188], // 2 — slot 2 (top=720) activates here; height capped at 188. - [176, 186], // 3 - [168, 182], // 4 - [156, 170], // 5 - [144, 158], // 6 - [136, 150], // 7 - [128, 142], // 8 - [120, 134], // 9 - [112, 126], // 10 -] as const +// Sticker size shrinks as the badge count grows, but stays large enough that +// the repulsion packs them across the whole field (not a thin scatter). Past +// the table it eases down a 2300/count curve with a legibility floor. +// Stickers are square (raw badge art, no frame). +const STICKER_SIZE_BY_COUNT: readonly number[] = [ + 520, // 1 — single hero sticker slapped on the card + 460, // 2 + 415, // 3 + 385, // 4 + 360, // 5 + 340, // 6 + 322, // 7 + 306, // 8 + 292, // 9 + 280, // 10 +] + +function stickerSize(count: number): number { + if (count <= STICKER_SIZE_BY_COUNT.length) return STICKER_SIZE_BY_COUNT[count - 1] + return Math.max(180, Math.round(2700 / count)) +} + +/** Does a size×size sticker centred at (cx,cy) intersect the pill keep-out + * (which extends to the bottom-right canvas corner)? */ +function hitsPill(cx: number, cy: number, half: number): boolean { + return cx + half > PILL_KEEPOUT.x0 - PILL_PAD && cy + half > PILL_KEEPOUT.y0 - PILL_PAD +} -export function placeStamps(badges: ShareAssetBadge[], rng: SeededRandom): StampPlacement[] { +/** An extra keep-out region (e.g. the hero message sticker at the top) that + * badge stickers must not cover. Canvas-coord ellipse. */ +export interface KeepoutEllipse { + cx: number + cy: number + rx: number + ry: number +} + +export function placeStamps( + badges: ShareAssetBadge[], + rng: SeededRandom, + extraKeepouts: readonly KeepoutEllipse[] = [] +): StampPlacement[] { const sorted = [...badges].sort((a, b) => { const aT = a.earnedAt ? new Date(a.earnedAt).getTime() : 0 const bT = b.earnedAt ? new Date(b.earnedAt).getTime() : 0 @@ -129,196 +145,106 @@ export function placeStamps(badges: ShareAssetBadge[], rng: SeededRandom): Stamp const count = sorted.length if (count === 0) return [] - // Size table is indexed 1..10; clamp anything above to the smallest tier. - const [width, height] = STAMP_SIZE_BY_COUNT[Math.min(count, STAMP_SIZE_BY_COUNT.length) - 1] - - // Shuffle once so two users with identical badge sets but different - // seeds get visually distinct layouts. - const shuffled = rng.shuffle(sorted) + const size = stickerSize(count) + const half = size / 2 + const minC = half - STICKER_OVERHANG + const maxCx = CANVAS_W - half + STICKER_OVERHANG + const maxCy = CANVAS_H - half + STICKER_OVERHANG + const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)) + const keepouts = [...CARD_KEEPOUTS, ...extraKeepouts] - return shuffled.map((badge, i): StampPlacement => { - // For i < STAMP_SLOTS.length, use a unique base slot. For overflow - // (i ≥ slot count), wrap with a per-cycle offset so the extras - // stack on existing slots as a natural pile (Hugo: "just stack - // them"). Per-cycle x/y shift + rotation tweak avoid pixel- - // identical duplicates. - const slotIdx = i % STAMP_SLOTS.length - const cycle = Math.floor(i / STAMP_SLOTS.length) - const base = STAMP_SLOTS[slotIdx] - const stackOffset = cycle * 22 - // `'25`-style year denomination. (An earlier pass switched this to the - // full 4-digit year chasing Konrad's "the ''' look buggy" note — that - // was actually about sparkle.svg's slash-strokes, not the year. Hugo - // 2026-06-11: the apostrophe year looked fine; bring it back.) - const year = badge.earnedAt ? `'${String(new Date(badge.earnedAt).getFullYear()).slice(-2)}` : undefined + // ── Seed positions: spread around the card by angle (so even a few + // stickers surround it) with a varied radius (so the relaxation has 2D + // room to fill, not just a ring). The first sticker starts up top. + const pos = sorted.map((_, i) => { + const theta = -Math.PI / 2 + ((i === 0 ? 0 : i) / count) * Math.PI * 2 + rng.float(-0.4, 0.4) + const rad = rng.float(1.05, 1.95) return { - badge: { - code: badge.code, - iconUrl: getBadgeIcon(badge.code), - year, - }, - top: base.top + stackOffset + rng.float(-base.jitterXY, base.jitterXY), - left: base.left + stackOffset + rng.float(-base.jitterXY, base.jitterXY), - rotation: base.rotation + cycle * 6 + rng.float(-base.jitterRot, base.jitterRot), - behind: base.behind, - withTape: !!base.withTape, - width, - height, + x: clamp(FIELD_CX + Math.cos(theta) * KEEP_A * rad, minC, maxCx), + y: clamp(FIELD_CY + Math.sin(theta) * KEEP_B * rad, minC, maxCy), } }) -} - -// ─── Decorations (stars + thumbs-up + peanut chars) ───────────────────── -// Pool of candidate positions in safe negative-space zones. -// `kind` selects which SVG. PRNG picks which subset to actually render. - -interface DecorationCandidate { - kind: 'star' | 'starAlt' | 'thumbsUp' | 'thumbsUpV2' | 'peace' | 'eyes' | 'cloud' | 'peanutChar' - top?: number - bottom?: number - left?: number - right?: number - size: number - rotation: number - /** Whether this position is "safe" when card has stamps behind it. - * Currently always true — pool is curated to avoid stamp slots. */ - safe: boolean -} - -// Decoration pool. No peanut characters — both peanut-raising-hands.svg -// and peanutman-waving.svg crop the lower body at the SVG source (it's -// the art style, not a bug we can fix). No sparkle.svg either — it's -// three loose slash-strokes that read as stray tick marks / apostrophes -// on the asset (Konrad's "the ''' look a bit buggy"). The pool sticks to -// stars, hands, eyes, and clouds so every decoration reads as a clearly -// formed shape. -// -// Layout-zone map on the 1200×900 canvas: -// - EARNED stamp: x[920..1200], y[20..200] (top-right) -// - EDITION + tier block: x[20..360], y[20..460] (left band) -// - @username pill: x[400..1144], y[770..900] (bottom-right) -// - Card bbox: x[264..884], y[254..645] (centre) -// - Stamp slots: see STAMP_SLOTS above -// All slots below sit OUTSIDE the chrome zones AND outside stamp slots. -const DECORATION_POOL: readonly DecorationCandidate[] = [ - // ── Top strip (y=16..160) — above the card, between EDITION left and - // EARNED right. Two natural columns left/right of the centre stamp. - { kind: 'cloud', top: 16, left: 880, size: 64, rotation: -4, safe: true }, - { kind: 'star', top: 36, left: 380, size: 72, rotation: 8, safe: true }, - { kind: 'peace', top: 52, left: 720, size: 56, rotation: -12, safe: true }, - { kind: 'thumbsUp', top: 56, left: 232, size: 92, rotation: -10, safe: true }, - { kind: 'thumbsUpV2', top: 80, left: 700, size: 76, rotation: 12, safe: true }, - { kind: 'eyes', top: 28, left: 580, size: 48, rotation: 6, safe: true }, - { kind: 'star', top: 130, left: 980, size: 40, rotation: 18, safe: true }, - { kind: 'starAlt', top: 110, left: 320, size: 52, rotation: -20, safe: true }, - - // ── Top-LEFT quadrant (Hugo flagged this as empty) — fits between - // the EDITION header (y<120) and the tier block (y>158, x<360). - // Tight space; small accents only. - { kind: 'star', top: 14, left: 56, size: 32, rotation: 22, safe: true }, - { kind: 'starAlt', top: 8, left: 460, size: 36, rotation: -16, safe: true }, - { kind: 'eyes', top: 130, left: 180, size: 36, rotation: 14, safe: true }, - - // ── Mid-right gap between stamp slot 3 (y≤396) and slot 5 (y≥470). - { kind: 'star', top: 410, right: 30, size: 44, rotation: 45, safe: true }, - { kind: 'peace', top: 420, right: 140, size: 42, rotation: -10, safe: true }, - - // ── Mid-left / mid-right margins outside the card. - { kind: 'cloud', top: 380, left: 26, size: 48, rotation: 10, safe: true }, - { kind: 'eyes', top: 540, right: 220, size: 38, rotation: 22, safe: true }, - { kind: 'starAlt', top: 480, right: 200, size: 44, rotation: -8, safe: true }, - - // ── Bottom-CENTRE (Hugo flagged this as empty) — between card-bottom - // (y=645) and the username pill top (y≥770). The pill ends at - // x≈400 worst case, so anything in x[120..380] is safely in the - // gutter to the LEFT of the pill. - { kind: 'star', top: 660, left: 200, size: 44, rotation: -14, safe: true }, - { kind: 'starAlt', top: 700, left: 320, size: 48, rotation: 18, safe: true }, - { kind: 'eyes', top: 670, left: 60, size: 40, rotation: -6, safe: true }, - { kind: 'thumbsUpV2', top: 690, left: 130, size: 64, rotation: 8, safe: true }, - - // ── Lower-right (small sprinkles between EARNED-zone clear point and - // the pill's top edge at y≈770). - { kind: 'peace', bottom: 220, right: 280, size: 52, rotation: -8, safe: true }, - { kind: 'star', bottom: 60, right: 360, size: 34, rotation: -12, safe: true }, - { kind: 'cloud', bottom: 80, right: 60, size: 56, rotation: 8, safe: true }, - - // ── Peanut character — peeks up from BEHIND the card. The peanut - // art style is legless (body tapers to a rounded point), so we - // place him with his bottom inside the card's bbox (y≥254) and - // only the head/arms visible above the card edge. Decorations - // render at z-index 1; the card at z-index 3, with overflow: - // hidden — so the body inside the card region is naturally - // clipped without any extra masking. Two candidate positions - // flanking the centre HERO stamp slot (top:140, left:504). - { kind: 'peanutChar', top: 120, left: 320, size: 180, rotation: -6, safe: true }, - { kind: 'peanutChar', top: 130, left: 760, size: 170, rotation: 8, safe: true }, -] as const - -export interface DecorationPlacement { - kind: 'star' | 'starAlt' | 'thumbsUp' | 'thumbsUpV2' | 'peace' | 'eyes' | 'cloud' | 'peanutChar' - top?: number - bottom?: number - left?: number - right?: number - size: number - rotation: number -} -/** Axis-aligned bounding-box of a placed decoration in canvas coords. - * Used by the non-overlap check below — treats every decoration as a - * size × size square (conservative; tall assets won't be over-tight). */ -function decorationBbox(d: { top?: number; bottom?: number; left?: number; right?: number; size: number }): { - x0: number - y0: number - x1: number - y1: number -} { - const left = d.left ?? CANVAS_W - (d.right ?? 0) - d.size - const top = d.top ?? CANVAS_H - (d.bottom ?? 0) - d.size - return { x0: left, y0: top, x1: left + d.size, y1: top + d.size } -} - -/** AABB intersection with a small padding so decorations aren't just-not-touching. */ -function bboxesOverlap( - a: { x0: number; y0: number; x1: number; y1: number }, - b: { x0: number; y0: number; x1: number; y1: number }, - pad = 8 -): boolean { - return !(a.x1 + pad < b.x0 || a.x0 - pad > b.x1 || a.y1 + pad < b.y0 || a.y0 - pad > b.y1) -} - -/** Greedy non-overlap placement. Walks the shuffled pool and accepts a - * candidate only if its bbox doesn't intersect any already-placed - * decoration. Target count 12-15; if the pool can't satisfy that - * (collisions), returns whatever fit — never compromises the no-overlap - * invariant. */ -export function placeDecorations(rng: SeededRandom): DecorationPlacement[] { - const target = rng.int(12, 15) - const shuffled = rng.shuffle([...DECORATION_POOL]) - const accepted: DecorationPlacement[] = [] - const acceptedBboxes: ReturnType[] = [] - - for (const d of shuffled) { - if (accepted.length >= target) break - const sizeJitter = rng.float(-4, 4) - const rotJitter = rng.float(-6, 6) - const placement: DecorationPlacement = { - kind: d.kind, - top: d.top, - bottom: d.bottom, - left: d.left, - right: d.right, - size: d.size + sizeJitter, - rotation: d.rotation + rotJitter, + // ── Relax: position-based constraints, Gauss–Seidel. Each pass separates + // overlapping stickers, shoves any inside the keep-clear ellipse back + // out, nudges them off the edges and out of the pill, then clamps to + // the canvas. Converges to an even spread filling the field. + const minDist = size * STICKER_GAP + for (let step = 0; step < RELAX_ITERS; step++) { + // sticker ↔ sticker separation + for (let i = 0; i < count; i++) { + for (let j = i + 1; j < count; j++) { + let dx = pos[j].x - pos[i].x + let dy = pos[j].y - pos[i].y + let dist = Math.hypot(dx, dy) + if (dist === 0) { + dx = rng.float(-1, 1) + dy = rng.float(-1, 1) + dist = Math.hypot(dx, dy) || 1 + } + if (dist < minDist) { + const corr = (minDist - dist) / 2 + const ux = dx / dist + const uy = dy / dist + pos[i].x -= ux * corr + pos[i].y -= uy * corr + pos[j].x += ux * corr + pos[j].y += uy * corr + } + } + } + // unary constraints + for (let i = 0; i < count; i++) { + const p = pos[i] + // targeted keep-outs (inflated by the sticker half so the mark is + // never covered) — peanut logo + pixel hand, plus any caller extras + // (the hero message sticker at the top). + for (const ko of keepouts) { + const rx = ko.rx + half + const ry = ko.ry + half + const kex = (p.x - ko.cx) / rx + const key = (p.y - ko.cy) / ry + const ke = Math.hypot(kex, key) + if (ke === 0) { + p.x += rx + } else if (ke < 1) { + const s = 1 / ke + p.x = ko.cx + (p.x - ko.cx) * s + p.y = ko.cy + (p.y - ko.cy) * s + } + } + // soft inward margin off the canvas edges + const loX = EDGE_MARGIN + half + const hiX = CANVAS_W - EDGE_MARGIN - half + const loY = EDGE_MARGIN + half + const hiY = CANVAS_H - EDGE_MARGIN - half + if (p.x < loX) p.x += (loX - p.x) * 0.5 + else if (p.x > hiX) p.x -= (p.x - hiX) * 0.5 + if (p.y < loY) p.y += (loY - p.y) * 0.5 + else if (p.y > hiY) p.y -= (p.y - hiY) * 0.5 + // pill keep-out: shove out along the shallower axis + if (hitsPill(p.x, p.y, half)) { + const exitLeft = p.x + half - (PILL_KEEPOUT.x0 - PILL_PAD) + const exitUp = p.y + half - (PILL_KEEPOUT.y0 - PILL_PAD) + if (exitLeft < exitUp) p.x -= exitLeft + else p.y -= exitUp + } + // hard clamp to canvas (within overhang) + p.x = clamp(p.x, minC, maxCx) + p.y = clamp(p.y, minC, maxCy) } - const bbox = decorationBbox(placement) - const collides = acceptedBboxes.some((other) => bboxesOverlap(bbox, other)) - if (collides) continue - accepted.push(placement) - acceptedBboxes.push(bbox) } - return accepted + + return pos.map( + (p, i): StampPlacement => ({ + badge: { code: sorted[i].code, iconUrl: getBadgeIcon(sorted[i].code) }, + top: p.y - half, + left: p.x - half, + rotation: rng.float(-15, 15), + width: size, + height: size, + }) + ) } // ─── Stats inline block ───────────────────────────────────────────────── diff --git a/src/constants/routes.ts b/src/constants/routes.ts index f4b834285..095b4bc65 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -111,7 +111,8 @@ export const PUBLIC_ROUTES_REGEX = /^\/(request\/pay|claim|pay\/.+|support|invit * Regex for dev-only public routes (dev index, gift-test, shake-test) * Only matched when IS_DEV is true */ -export const DEV_ONLY_PUBLIC_ROUTES_REGEX = /^\/(dev$|dev\/gift-test|dev\/shake-test|dev\/ds|dev\/components)/ +export const DEV_ONLY_PUBLIC_ROUTES_REGEX = + /^\/(dev$|dev\/gift-test|dev\/shake-test|dev\/ds|dev\/components|dev\/share-builder)/ /** * Matches locale tags with a required subtag to avoid false-positives on short diff --git a/src/styles/globals.css b/src/styles/globals.css index 40a95962b..b9d6a5025 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -808,66 +808,3 @@ input::placeholder { .content-page tbody tr:nth-child(even) { @apply bg-primary-3/30; } - -/* ───────────────────────────────────────────────────────────────── - Share-asset stamp — real postage-stamp appearance for the - ShareAssetD3 component. Scalloped perforated edges, inner thin - frame, paper-warm body, hard shadow. Override `--stamp-bg` per - parent so the perforation dots match the canvas behind it. - - Used in: src/components/Card/share-asset/ShareAssetD3.tsx - ───────────────────────────────────────────────────────────────── */ -.stamp { - --stamp-bg: #ffc900; - - position: relative; - background: #fff; - background-image: radial-gradient(ellipse at top left, #fff8e1 0%, transparent 60%); - filter: drop-shadow(0.18rem 0.18rem 0 #000); -} -.stamp::before { - content: ''; - position: absolute; - inset: -7px; - pointer-events: none; - background: - radial-gradient(circle 4.5px at center, var(--stamp-bg) 99%, transparent 100%) 0 0 / 14px 14px, - radial-gradient(circle 4.5px at center, var(--stamp-bg) 99%, transparent 100%) 0 100% / 14px 14px, - radial-gradient(circle 4.5px at center, var(--stamp-bg) 99%, transparent 100%) 0 0 / 14px 14px, - radial-gradient(circle 4.5px at center, var(--stamp-bg) 99%, transparent 100%) 100% 0 / 14px 14px; - background-repeat: repeat-x, repeat-x, repeat-y, repeat-y; -} -.stamp::after { - content: ''; - position: absolute; - inset: 7px; - border: 1px solid #000; - pointer-events: none; -} -.stamp-issuer { - position: absolute; - top: 11px; - left: 0; - right: 0; - text-align: center; - font-family: var(--font-roboto), sans-serif; - font-weight: 900; - font-size: 8px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: #000; - opacity: 0.6; - z-index: 2; -} -.stamp-denom { - position: absolute; - top: 11px; - right: 13px; - font-family: var(--font-roboto), sans-serif; - font-weight: 900; - font-size: 9px; - letter-spacing: 0.04em; - color: #000; - opacity: 0.55; - z-index: 2; -} From abc0faff8cd537b5b872b3e68f20019e1c25c755 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Tue, 23 Jun 2026 21:27:10 +0000 Subject: [PATCH 02/52] fix(card): match share-asset keep-outs to rendered hero + pill CodeRabbit review on #2274: - Size the username-pill keep-out to the rendered pill (peanut.me/ widens with the handle); a fixed x0 only guarded the right edge, so a sticker could land on a long handle. Pill box now computed from the rendered geometry and passed into placeStamps; add a regression test. - Reserve the *rotated* hero bounding box so a tilted hero sticker's corners aren't covered; extract shared heroTilt() used by render + keep-out. - Correct the heroMessage/usernameStyle prop docs (undefined=default hero, null=none; pill defaults to white). --- .../Card/share-asset/ShareAssetD3.tsx | 62 +++++++++++++++---- .../__tests__/shareAssetLayout.test.ts | 28 +++++++++ .../Card/share-asset/shareAsset.types.ts | 6 +- .../Card/share-asset/shareAssetLayout.ts | 35 ++++++++--- 4 files changed, 109 insertions(+), 22 deletions(-) diff --git a/src/components/Card/share-asset/ShareAssetD3.tsx b/src/components/Card/share-asset/ShareAssetD3.tsx index 7c56bdfac..47fbecc76 100644 --- a/src/components/Card/share-asset/ShareAssetD3.tsx +++ b/src/components/Card/share-asset/ShareAssetD3.tsx @@ -23,7 +23,10 @@ import { CARD_LEFT, CARD_TOP, CARD_ROTATION_DEG, + PILL_RIGHT, + PILL_BOTTOM, placeStamps, + pillKeepoutBox, usernameFontSize, type StampPlacement, type KeepoutEllipse, @@ -45,6 +48,10 @@ const ANIM_ATTRIBUTION_DELAY = 1700 const HERO_TOP = 26 const HERO_CX = CANVAS_W / 2 +// Max rendered width of the username pill (caps very long handles). Shared by +// the pill render and the layout keep-out estimate so they stay in lockstep. +const PILL_MAX_W = 780 + /** Nominal pixel footprint of the hero sticker, used both to render it and to * reserve a keep-out so badges don't cover it. Width grows with the copy. */ function heroGeometry(msg: HeroMessage): { w: number; h: number; fontSize: number } { @@ -63,6 +70,13 @@ function heroGeometry(msg: HeroMessage): { w: number; h: number; fontSize: numbe return { w, h, fontSize } } +/** Resolved tilt (deg) for a hero message — explicit override, else a small + * per-variant lean. Shared by the renderer (HeroMessageEl) and the keep-out + * calc so the reserved region matches the *rotated* sticker. */ +function heroTilt(msg: HeroMessage): number { + return msg.tilt ?? (msg.variant === 'banner' ? -2 : msg.variant === 'pill' ? -3 : -4) +} + const USERNAME_BG: Record['bg'], string> = { white: '#FFFFFF', pink: '#FF90E8', @@ -97,13 +111,6 @@ const ShareAssetD3: FC = ({ const hero = resolvedHero && resolvedHero.text.trim() ? resolvedHero : null const heroGeo = useMemo(() => (hero ? heroGeometry(hero) : null), [hero?.text, hero?.variant, hero?.scale]) - const stickers = useMemo(() => { - const extraKeepouts: KeepoutEllipse[] = heroGeo - ? [{ cx: HERO_CX, cy: HERO_TOP + heroGeo.h / 2, rx: heroGeo.w / 2, ry: heroGeo.h / 2 }] - : [] - return placeStamps(badges, new SeededRandom(seedOverride ?? safeUsername), extraKeepouts) - }, [seedOverride, safeUsername, badges, heroGeo]) - // Username pill: "peanut.me/" — the prefix is rendered much smaller // than the handle. Colour + typography come from usernameStyle. const uHandleSize = usernameFontSize(safeUsername) * (usernameStyle?.scale ?? DEFAULT_USERNAME_STYLE.scale) @@ -111,6 +118,35 @@ const ShareAssetD3: FC = ({ const uTracking = usernameStyle?.letterSpacing ?? DEFAULT_USERNAME_STYLE.letterSpacing const uBg = USERNAME_BG[usernameStyle?.bg ?? DEFAULT_USERNAME_STYLE.bg] + // Estimate the rendered pill footprint so the layout engine keeps badges + // off the *whole* pill (its width varies with the handle + typography), not + // just its right edge. Mirrors the pill's render geometry below. + const pillBox = useMemo(() => { + const PREFIX_CHARS = 'peanut.me/'.length + const PAD_X = 80 // 40px horizontal padding each side + const BORDER = 10 // 5px border each side + const prefixW = PREFIX_CHARS * uPrefixSize * 0.6 + const handleW = safeUsername.length * uHandleSize * (0.62 + Math.max(0, uTracking)) + const pillW = Math.min(PILL_MAX_W, prefixW + handleW + PAD_X + BORDER) + const pillH = uHandleSize * 1.1 + 30 + return pillKeepoutBox(pillW, pillH) + }, [safeUsername, uPrefixSize, uHandleSize, uTracking]) + + const stickers = useMemo(() => { + const extraKeepouts: KeepoutEllipse[] = [] + if (heroGeo && hero) { + // Reserve the *rotated* hero bounding box — the sticker is tilted, + // so its corners reach beyond the unrotated w×h ellipse. + const rad = Math.abs((heroTilt(hero) * Math.PI) / 180) + const cos = Math.abs(Math.cos(rad)) + const sin = Math.abs(Math.sin(rad)) + const rw = (heroGeo.w * cos + heroGeo.h * sin) / 2 + const rh = (heroGeo.w * sin + heroGeo.h * cos) / 2 + extraKeepouts.push({ cx: HERO_CX, cy: HERO_TOP + heroGeo.h / 2, rx: rw, ry: rh }) + } + return placeStamps(badges, new SeededRandom(seedOverride ?? safeUsername), extraKeepouts, pillBox) + }, [seedOverride, safeUsername, badges, heroGeo, hero, pillBox]) + return (
= ({
= ({ textTransform: 'lowercase', boxShadow: '0.375rem 0.375rem 0 #000', whiteSpace: 'nowrap', - maxWidth: 780, + maxWidth: PILL_MAX_W, overflow: 'hidden', lineHeight: 1.05, transform: 'rotate(-3deg)', @@ -292,9 +328,9 @@ const StickerEl: FC = ({ sticker, animate, delay }) => { const HeroMessageEl: FC<{ hero: HeroMessage; geo: { w: number; h: number; fontSize: number } }> = ({ hero, geo }) => { const { text, variant } = hero const { w, h, fontSize } = geo - // Tilt: explicit override, else a small per-variant lean. - const tilt = hero.tilt ?? (variant === 'banner' ? -2 : variant === 'pill' ? -3 : -4) - const rot = `rotate(${tilt}deg)` + // Tilt: explicit override, else a small per-variant lean (shared with the + // layout keep-out via heroTilt so the reserved region matches the render). + const rot = `rotate(${heroTilt(hero)}deg)` if (variant === 'pill') { return ( diff --git a/src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts b/src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts index 0ebd2e2ed..ae5d3a233 100644 --- a/src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts +++ b/src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts @@ -15,6 +15,7 @@ import { CARD_LEFT, CARD_TOP, placeStamps, + pillKeepoutBox, buildStatColumns, usernameFontSize, } from '../shareAssetLayout' @@ -185,6 +186,33 @@ describe('placeStamps', () => { const placed = placeStamps(badges, new SeededRandom('kkonrad')) expect(placed.length).toBe(13) }) + + // The username pill is the asset's "this is ME" anchor — a sticker must + // never cover it. The keep-out is sized to the *rendered* pill (which + // widens with the handle), so for a wide pill no sticker's bbox may enter + // its bottom-right rectangle. (Regression: a fixed x0 only guarded the + // pill's right edge, so stickers could land on a long handle.) + it('keeps stickers clear of the rendered username pill box', () => { + const pill = pillKeepoutBox(700, 132) // ≈ a long "peanut.me/" pill + const seeds = ['kkonrad', 'longusername', 'hugo', 'mara', '0', 'twelvecharsxx'] + for (let n = 1; n <= 10; n++) { + const badges = Array.from({ length: n }, (_, i) => badge(`B${i}`)) + for (const seed of seeds) { + const placed = placeStamps(badges, new SeededRandom(seed), [], pill) + for (const s of placed) { + const intrudesRight = s.left + s.width > pill.x0 + const intrudesBottom = s.top + s.height > pill.y0 + if (intrudesRight && intrudesBottom) { + throw new Error( + `Sticker covers the pill at count=${n} seed="${seed}": ` + + `bbox right=${(s.left + s.width).toFixed(0)} bottom=${(s.top + s.height).toFixed(0)} ` + + `vs pill x0=${pill.x0.toFixed(0)} y0=${pill.y0.toFixed(0)}` + ) + } + } + } + } + }) }) describe('buildStatColumns', () => { diff --git a/src/components/Card/share-asset/shareAsset.types.ts b/src/components/Card/share-asset/shareAsset.types.ts index 1742e4b62..24a9128ec 100644 --- a/src/components/Card/share-asset/shareAsset.types.ts +++ b/src/components/Card/share-asset/shareAsset.types.ts @@ -75,10 +75,12 @@ export interface ShareAssetD3Props { /** Last 4 of card PAN — rendered on the card face. */ cardLast4?: string - /** Optional hero "I got in" message sticker at the top. Null/omitted = none. */ + /** Optional hero "I got in" message sticker at the top. + * Omitted (`undefined`) = the shipped default hero; `null` = no hero. */ heroMessage?: HeroMessage | null - /** Username pill colour + typography. Defaults to pink with auto-fit sizing. */ + /** Username pill colour + typography. Defaults to a white pill with + * auto-fit sizing. */ usernameStyle?: UsernameStyle /** diff --git a/src/components/Card/share-asset/shareAssetLayout.ts b/src/components/Card/share-asset/shareAssetLayout.ts index d7afe87c7..4406c4aa3 100644 --- a/src/components/Card/share-asset/shareAssetLayout.ts +++ b/src/components/Card/share-asset/shareAssetLayout.ts @@ -75,9 +75,29 @@ const STICKER_OVERHANG = 24 // extends to the bottom-right canvas corner; PILL_PAD adds a margin so the // repulsion leaves a gap, not just a touch. Any sticker pushed inside is // shoved back out each relaxation pass — the pill repels like everything else. -const PILL_KEEPOUT = { x0: 690, y0: 712 } as const +// The pill's real width varies with the username + typography, so the caller +// passes a box sized to the *rendered* pill (see pillKeepoutBox); this default +// is a conservative fallback for callers that can't measure it. +const DEFAULT_PILL_KEEPOUT = { x0: 690, y0: 712 } as const const PILL_PAD = 26 +// Bottom-right anchor of the username pill — mirrors ShareAssetD3's render +// (`bottom: PILL_BOTTOM, right: PILL_RIGHT`). Exported so the component keeps +// the render and the keep-out in lockstep. +export const PILL_RIGHT = 56 +export const PILL_BOTTOM = 48 + +/** Top-left corner of the username pill's bounding box, given its rendered + * size. The pill is anchored bottom-right, so the keep-out's top-left walks + * left/up as the pill grows — protecting the *whole* pill, not just its + * right edge (which a fixed x0 missed for long handles). */ +export function pillKeepoutBox(pillW: number, pillH: number): { x0: number; y0: number } { + return { + x0: CANVAS_W - PILL_RIGHT - pillW, + y0: CANVAS_H - PILL_BOTTOM - pillH, + } +} + // Relaxation iterations + how close two sticker centres may sit (× combined // radii). 1.0 = just touching; below 1.0 allows a bit of overlap while the // solver still actively repels them apart each pass. @@ -119,8 +139,8 @@ function stickerSize(count: number): number { /** Does a size×size sticker centred at (cx,cy) intersect the pill keep-out * (which extends to the bottom-right canvas corner)? */ -function hitsPill(cx: number, cy: number, half: number): boolean { - return cx + half > PILL_KEEPOUT.x0 - PILL_PAD && cy + half > PILL_KEEPOUT.y0 - PILL_PAD +function hitsPill(cx: number, cy: number, half: number, pill: { x0: number; y0: number }): boolean { + return cx + half > pill.x0 - PILL_PAD && cy + half > pill.y0 - PILL_PAD } /** An extra keep-out region (e.g. the hero message sticker at the top) that @@ -135,7 +155,8 @@ export interface KeepoutEllipse { export function placeStamps( badges: ShareAssetBadge[], rng: SeededRandom, - extraKeepouts: readonly KeepoutEllipse[] = [] + extraKeepouts: readonly KeepoutEllipse[] = [], + pill: { x0: number; y0: number } = DEFAULT_PILL_KEEPOUT ): StampPlacement[] { const sorted = [...badges].sort((a, b) => { const aT = a.earnedAt ? new Date(a.earnedAt).getTime() : 0 @@ -223,9 +244,9 @@ export function placeStamps( if (p.y < loY) p.y += (loY - p.y) * 0.5 else if (p.y > hiY) p.y -= (p.y - hiY) * 0.5 // pill keep-out: shove out along the shallower axis - if (hitsPill(p.x, p.y, half)) { - const exitLeft = p.x + half - (PILL_KEEPOUT.x0 - PILL_PAD) - const exitUp = p.y + half - (PILL_KEEPOUT.y0 - PILL_PAD) + if (hitsPill(p.x, p.y, half, pill)) { + const exitLeft = p.x + half - (pill.x0 - PILL_PAD) + const exitUp = p.y + half - (pill.y0 - PILL_PAD) if (exitLeft < exitUp) p.x -= exitLeft else p.y -= exitUp } From 8cd3cb38b81c26d868539d64b8c7e07d6347c2c2 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Tue, 23 Jun 2026 21:47:34 +0000 Subject: [PATCH 03/52] feat(card): Berghain-style "not tonight" rejection on the waitlist path Users who pass the eligibility hold but lack a card-access badge hit a flat "you don't have the required badge :(" wall. Replace it with a shareable door rejection that doubles as a growth loop: a "not tonight, " asset (smug peanut bouncer, scarcity tally as screen copy) plus a primary "Tweet to appeal" share that attaches the asset and tags @joinpeanut with a random caption. The secondary "Join the waitlist anyway" still calls joinWaitlist, flipping the state machine to the friendly joined screen as the post-share cooldown. CardRejectionScreen owns the join itself (mirrors the old CardWaitlistScreen contract: joinWaitlist + posthog + loading/error), so the /card state machine drops it straight into the not-joined slot. Removes the now-orphaned CardWaitlistScreen. Adds /dev/rejection-builder to iterate on copy, the door tally, and the bouncer mascot. --- src/app/(mobile-ui)/card/page.tsx | 18 +- .../dev/rejection-builder/page.tsx | 176 ++++++++++++++++ .../Card/CardEligibilityCheckScreen.tsx | 2 +- src/components/Card/CardRejectionScreen.tsx | 166 +++++++++++++++ src/components/Card/CardWaitlistScreen.tsx | 100 --------- .../Card/share-asset/RejectionAssetD3.tsx | 192 ++++++++++++++++++ .../Card/share-asset/ScaledRejectionAsset.tsx | 55 +++++ .../Card/share-asset/rejectionCaptions.ts | 41 ++++ .../Card/share-asset/shareAsset.types.ts | 5 + src/constants/routes.ts | 2 +- 10 files changed, 650 insertions(+), 107 deletions(-) create mode 100644 src/app/(mobile-ui)/dev/rejection-builder/page.tsx create mode 100644 src/components/Card/CardRejectionScreen.tsx delete mode 100644 src/components/Card/CardWaitlistScreen.tsx create mode 100644 src/components/Card/share-asset/RejectionAssetD3.tsx create mode 100644 src/components/Card/share-asset/ScaledRejectionAsset.tsx create mode 100644 src/components/Card/share-asset/rejectionCaptions.ts diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index dab8fa04b..dce8827c7 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -12,7 +12,7 @@ import { pollUntilApplyAdvances, pollUntilReady } from '@/components/Card/cardAp import AddCardEntryScreen from '@/components/Card/AddCardEntryScreen' import ApplicationStatusScreen from '@/components/Card/ApplicationStatusScreen' import CardTermsScreen from '@/components/Card/CardTermsScreen' -import CardWaitlistScreen from '@/components/Card/CardWaitlistScreen' +import CardRejectionScreen from '@/components/Card/CardRejectionScreen' import CardWaitlistJoinedScreen from '@/components/Card/CardWaitlistJoinedScreen' import BadgeSkipCelebration from '@/components/Card/BadgeSkipCelebration' import CardEligibilityCheckScreen from '@/components/Card/CardEligibilityCheckScreen' @@ -471,13 +471,21 @@ const CardPage: FC = () => { ) case 'waitlist': { // Joined vs not-joined are two distinct screens — keeps each - // tight to its own purpose (let-down + CTA vs confirmation + - // exit). The skip-badge gallery was dropped per design: the - // not-joined view is a conversion moment, not a hunt prompt. + // tight to its own purpose. Not-joined is the Berghain-style + // "not tonight" rejection: a shareable door let-down (tags + // @joinpeanut) that doubles as the waitlist-join CTA. Once + // they join, the state machine flips to the friendly + // cooldown. if (cardInfo!.waitlistJoinedAt) { return } - return + return ( + + ) } case 'waitlist-skip-celebration': { // Pick the freshest skip badge for the celebration headline. diff --git a/src/app/(mobile-ui)/dev/rejection-builder/page.tsx b/src/app/(mobile-ui)/dev/rejection-builder/page.tsx new file mode 100644 index 000000000..544119223 --- /dev/null +++ b/src/app/(mobile-ui)/dev/rejection-builder/page.tsx @@ -0,0 +1,176 @@ +'use client' + +/** + * /dev/rejection-builder — iterator for the full mobile rejection screen + * (CardRejectionScreen): the "not tonight, " asset + the scarcity + * explainer copy + the "Tweet to appeal" CTA, previewed inside a phone frame. + * + * Knobs feed the whole screen so we can dial in the copy, the door tally, and + * which smug peanut bouncer shows on the asset. "Tweet to appeal" fires the + * real share path with a random caption (rejectionCaptions.ts). + */ + +import { useState } from 'react' +import NavHeader from '@/components/Global/NavHeader' +import CardRejectionScreen from '@/components/Card/CardRejectionScreen' +import type { RejectionMascot } from '@/components/Card/share-asset/shareAsset.types' + +const MASCOTS: ReadonlyArray<[RejectionMascot, string]> = [ + ['none', 'none'], + ['cool', 'cool (shades)'], + ['mock', 'mock (point + laugh)'], + ['chill', 'chill (whistling)'], +] + +export default function RejectionBuilderPage() { + const [username, setUsername] = useState('kkonrad') + const [mascot, setMascot] = useState('cool') + const [applicants, setApplicants] = useState(213) + const [admitted, setAdmitted] = useState(7) + + return ( +
+ + +
+ {/* ─── LEFT: Controls ──────────────────────────────────── */} + + + {/* ─── RIGHT: Phone-frame preview of the whole screen ──── */} +
+
+ mobile screen · CardRejectionScreen +
+
+
+ alert('→ joined: would advance to the friendly waitlist-joined screen')} + /> +
+
+
+
+
+ ) +} + +// ─── Small UI primitives ──────────────────────────────────────────────── + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ) +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ) +} + +function PresetButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) { + return ( + + ) +} diff --git a/src/components/Card/CardEligibilityCheckScreen.tsx b/src/components/Card/CardEligibilityCheckScreen.tsx index 3546b9106..c7f4ca3ba 100644 --- a/src/components/Card/CardEligibilityCheckScreen.tsx +++ b/src/components/Card/CardEligibilityCheckScreen.tsx @@ -12,7 +12,7 @@ * * On hold-complete the parent state machine decides the next view: * - has card access → BadgeSkipCelebration (share-asset reveal) - * - no card access → CardWaitlistScreen + * - no card access → CardRejectionScreen ("not tonight" door rejection) */ import { type FC, useEffect, useState } from 'react' diff --git a/src/components/Card/CardRejectionScreen.tsx b/src/components/Card/CardRejectionScreen.tsx new file mode 100644 index 000000000..a0dcc337e --- /dev/null +++ b/src/components/Card/CardRejectionScreen.tsx @@ -0,0 +1,166 @@ +'use client' + +/** + * — the Berghain-style "not tonight" rejection. + * + * Shown to a user who passed the eligibility hold but doesn't hold a card- + * access badge. Instead of a flat "you don't have the badge" wall, they get + * a shareable door rejection: a dark "not tonight, " asset with a + * smug peanut bouncer, the scarcity tally as screen copy, and a primary + * "Tweet to appeal" CTA that shares the asset (random caption tagging + * @joinpeanut). The friendly waitlist-joined screen is the cooldown AFTER + * they share, so they don't rage-quit. + */ + +import { type FC, useEffect, useRef, useState } from 'react' +import * as Sentry from '@sentry/nextjs' +import { Button } from '@/components/0_Bruddle/Button' +import NavHeader from '@/components/Global/NavHeader' +import ErrorAlert from '@/components/Global/ErrorAlert' +import { ScaledRejectionAsset } from '@/components/Card/share-asset/ScaledRejectionAsset' +import { captureShareAsset, canShareImageFiles } from '@/components/Card/share-asset/captureShareAsset' +import { pickRejectionCaption } from '@/components/Card/share-asset/rejectionCaptions' +import type { RejectionMascot } from '@/components/Card/share-asset/shareAsset.types' +import { cardApi } from '@/services/card' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' + +interface Props { + username?: string + /** Door tally — scarcity flex, rendered as screen copy (not on the asset). */ + applicants?: number + admitted?: number + /** Which smug mascot the asset shows. */ + mascot?: RejectionMascot + onPrev?: () => void + /** Called after the user joins the waitlist. Parent should refetch /card, + * which flips the state machine to (cooldown). */ + onJoined?: () => void +} + +const CardRejectionScreen: FC = ({ + username, + applicants = 213, + admitted = 7, + mascot = 'cool', + onPrev, + onJoined, +}) => { + const captureRef = useRef(null) + const [sharing, setSharing] = useState(false) + const [joining, setJoining] = useState(false) + const [joinError, setJoinError] = useState(null) + const safeUsername = (username || '').trim() || 'anon' + + useEffect(() => { + posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_VIEWED, { already_joined: false }) + }, []) + + const handleJoin = async (): Promise => { + setJoinError(null) + setJoining(true) + try { + const res = await cardApi.joinWaitlist() + posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_JOINED, { position: res.position }) + onJoined?.() + } catch (e) { + // Keep the user-facing message generic; raw BE text can leak + // internals/PII. PostHog gets only the error CLASS for bucketing. + console.error('[card-rejection] join failed', e) + setJoinError('Failed to join waitlist. Please try again.') + posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_JOIN_FAILED, { + error_name: e instanceof Error ? e.name : 'unknown', + }) + } finally { + setJoining(false) + } + } + + const handleAppeal = async (): Promise => { + const caption = pickRejectionCaption() + setSharing(true) + try { + if (!canShareImageFiles()) { + posthog.capture(ANALYTICS_EVENTS.CARD_SHARE_ASSET_SHARED, { + source: 'rejection-appeal', + method: 'twitter-intent-fallback', + }) + window.open( + `https://twitter.com/intent/tweet?text=${encodeURIComponent(caption)}`, + '_blank', + 'noopener' + ) + return + } + const node = captureRef.current + if (!node) throw new Error('rejection asset not yet rendered — try again in a moment') + const blob = await captureShareAsset(node) + const file = new File([blob], 'peanut-not-tonight.png', { type: 'image/png' }) + await navigator.share({ text: caption, files: [file] }) + posthog.capture(ANALYTICS_EVENTS.CARD_SHARE_ASSET_SHARED, { + source: 'rejection-appeal', + method: 'native-share-with-file', + }) + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return + console.error('[card-rejection] appeal share failed', err) + Sentry.captureException(err, { tags: { feature: 'rejection-asset', action: 'appeal' } }) + } finally { + setSharing(false) + } + } + + return ( +
+ + +
+ + + {/* Scarcity tally + appeal pitch — screen HTML, not on the asset */} +
+

the door's tight tonight.

+

+ {applicants.toLocaleString('en-US')} tried ·{' '} + {admitted} got in. +

+

+ tweet and tag @joinpeanut to appeal. +
+ come back tomorrow +

+
+
+ +
+ {joinError && } + + +
+
+ ) +} + +export default CardRejectionScreen diff --git a/src/components/Card/CardWaitlistScreen.tsx b/src/components/Card/CardWaitlistScreen.tsx deleted file mode 100644 index 6bbc4f6fb..000000000 --- a/src/components/Card/CardWaitlistScreen.tsx +++ /dev/null @@ -1,100 +0,0 @@ -'use client' - -/** - * — shown to users who passed the eligibility - * check but don't hold a skip badge AND haven't joined the waitlist yet. - * - * Tone: gentle redirect, not a wall. Uses the pointing-peanut mascot - * (PeanutPointing — grinning, nudging toward the waitlist), - * one-line "you don't have the required badge" headline, soft "but you - * can join the waitlist" pitch, single CTA. No skip-the-line gallery - * here — the goal is conversion to waitlist, not badge-hunting. - * - * Once joined, the /card state machine routes to . - */ - -import { type FC, useEffect, useState } from 'react' -import Image from 'next/image' -import { Button } from '@/components/0_Bruddle/Button' -import NavHeader from '@/components/Global/NavHeader' -import ErrorAlert from '@/components/Global/ErrorAlert' -import { PeanutPointing } from '@/assets/mascot' -import posthog from 'posthog-js' -import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { cardApi, type CardInfoResponse } from '@/services/card' - -interface Props { - cardInfo: CardInfoResponse - onPrev?: () => void - /** Called after the user joins. Parent should refetch /card. */ - onJoined?: () => void -} - -const CardWaitlistScreen: FC = ({ onPrev, onJoined }) => { - const [joining, setJoining] = useState(false) - const [joinError, setJoinError] = useState(null) - - useEffect(() => { - posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_VIEWED, { already_joined: false }) - }, []) - - const handleJoin = async (): Promise => { - setJoinError(null) - setJoining(true) - try { - const res = await cardApi.joinWaitlist() - posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_JOINED, { position: res.position }) - onJoined?.() - } catch (e) { - // User-facing message stays generic; raw BE error text can leak - // internals/PII. PostHog gets the error CLASS only — enough to - // bucket failures without exposing details. - console.error('[card-waitlist] join failed', e) - setJoinError('Failed to join waitlist. Please try again.') - posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_JOIN_FAILED, { - error_name: e instanceof Error ? e.name : 'unknown', - }) - } finally { - setJoining(false) - } - } - - return ( -
- - -
- Peanut pointing the way - -
-

You don't have the required badge :(

-

- Instead, you can join the waitlist. We let in a few people every week. -

-
-
- - {joinError && } - - -
- ) -} - -export default CardWaitlistScreen diff --git a/src/components/Card/share-asset/RejectionAssetD3.tsx b/src/components/Card/share-asset/RejectionAssetD3.tsx new file mode 100644 index 000000000..712258d53 --- /dev/null +++ b/src/components/Card/share-asset/RejectionAssetD3.tsx @@ -0,0 +1,192 @@ +/** + * — the "not tonight" rejection share asset. + * + * Shown to a user who passed the eligibility hold but doesn't hold a card- + * access badge: instead of the celebration collage, they get a Berghain- + * style door rejection they can share. The rejection markets the door's + * exclusivity, and the share tags @joinpeanut (baked into the image + the + * caption — see rejectionCaptions.ts). + * + * Visual: stark, near-black field. "not tonight, " in big white, + * an optional smug peanut mascot on the left (the bouncer, mocking you), + * and the @joinpeanut handle baked in so the tag survives image-only + * re-posts. The scarcity tally ("applicants tonight…") lives in the screen + * HTML around the asset, NOT on the image. + * + * Authored at native 1200×900 (same frame as the win asset) so it flows + * through the same capture/share pipeline. + */ + +'use client' + +import { type FC } from 'react' +import { CANVAS_W, CANVAS_H } from './shareAssetLayout' +import type { RejectionMascot } from './shareAsset.types' +import { PeanutTooCool, PeanutPointing, PeanutWhistling } from '@/assets/mascot' + +const MASCOT_SRC: Record, string> = { + cool: PeanutTooCool.src, // pixel shades, hand on hip — "not cool enough" + mock: PeanutPointing.src, // grinning + pointing — point and laugh + chill: PeanutWhistling.src, // whistling, peace-sign — dismissive "whatever" +} + +interface RejectionAssetProps { + username: string + /** Leading line. Default "not tonight,". */ + prefix?: string + /** Which mascot to slap on the left. 'none' hides it. */ + mascot?: RejectionMascot + /** Mascot size multiplier. */ + mascotScale?: number + /** Background fill (near-black). */ + bg?: string + /** Headline cap height in px + tracking in em. */ + headlineSize?: number + headlineTracking?: number + /** Subtle edge darkening for depth, 0–1. */ + vignette?: number + animate?: boolean +} + +const DEFAULT_BG = '#0a0b0f' // near-black, faint cold-blue tint + +const ANIM_HEADLINE_DELAY = 150 +const ANIM_MASCOT_DELAY = 350 + +const RejectionAssetD3: FC = ({ + username, + prefix = 'not tonight,', + mascot = 'cool', + mascotScale = 1, + bg = DEFAULT_BG, + headlineSize = 130, + headlineTracking = -0.02, + vignette = 0.35, + animate = true, +}) => { + const safeUsername = (username || '').trim() || 'anon' + const hasMascot = mascot !== 'none' + const mascotSrc = hasMascot ? MASCOT_SRC[mascot] : null + + // Mascot eats the left third; the headline lives in the remaining space. + const mascotH = 640 * mascotScale + const textLeft = hasMascot ? 470 : 80 + const textRight = 80 + + // Auto-shrink the username so long handles still fit the text column. + const colW = CANVAS_W - textLeft - textRight + const unameSize = Math.min(headlineSize, (colW / Math.max(safeUsername.length, 5)) * 1.7) + + return ( +
+ + + {/* ─── Subtle vignette for depth (no spotlight) ─── */} + {vignette > 0 && ( +
+ )} + + {/* ─── Mascot (left) — the bouncer, mocking you ─── */} + {mascotSrc && ( + + )} + + {/* ─── Headline — "not tonight, " ─── */} +
+ + {prefix} + + + {safeUsername} + +
+
+ ) +} + +export default RejectionAssetD3 diff --git a/src/components/Card/share-asset/ScaledRejectionAsset.tsx b/src/components/Card/share-asset/ScaledRejectionAsset.tsx new file mode 100644 index 000000000..96469db4d --- /dev/null +++ b/src/components/Card/share-asset/ScaledRejectionAsset.tsx @@ -0,0 +1,55 @@ +'use client' + +/** + * — renders scaled to fit its + * container width, forwarding a capture ref at the native 1200×900 node so + * capture/share works at full fidelity. Mirrors . + */ + +import { type ComponentProps, type ForwardedRef, forwardRef } from 'react' +import RejectionAssetD3 from './RejectionAssetD3' +import { useFitToWidth } from '@/hooks/useFitToWidth' +import { CANVAS_W, CANVAS_H } from './shareAssetLayout' + +type Props = ComponentProps & { + /** Extra className for the outer host (the one ResizeObserver measures). */ + className?: string +} + +export const ScaledRejectionAsset = forwardRef(function ScaledRejectionAsset( + { className, ...assetProps }: Props, + captureRef: ForwardedRef +) { + const { hostRef, scale } = useFitToWidth(CANVAS_W) + + return ( +
+ {scale > 0 && ( +
+ +
+ )} +
+ ) +}) + +export default ScaledRejectionAsset diff --git a/src/components/Card/share-asset/rejectionCaptions.ts b/src/components/Card/share-asset/rejectionCaptions.ts new file mode 100644 index 000000000..b18ad55e5 --- /dev/null +++ b/src/components/Card/share-asset/rejectionCaptions.ts @@ -0,0 +1,41 @@ +/** + * Caption pool for the "NOT TONIGHT" rejection share asset. + * + * The waitlist rejection screen lets a turned-away user "Tweet to appeal" — + * the share fires with a RANDOM caption from this pool so the timeline + * doesn't fill with one identical tweet. Every caption tags @joinpeanut so + * the rejection itself markets the door's exclusivity. + * + * The @joinpeanut handle is also baked into the asset image, so the tag + * survives even when the PNG is re-posted with no caption. + */ + +export const REJECTION_CAPTIONS: readonly string[] = [ + 'rejected by @joinpeanut 🚫 the door policy is insane. i WILL be back.', + "@joinpeanut told me i'm not on the list 💀 the AUDACITY", + "the @joinpeanut bouncer said come back when i'm somebody. ok bet.", + 'got read for filth by the @joinpeanut door', + 'officially rejected by @joinpeanut. honestly an honor 🥜🚫', + 'collected my first @joinpeanut badge: REJECTED 💀', + 'took an L at the @joinpeanut door. building my comeback arc.', + "denied at the @joinpeanut door. the bouncer didn't even blink 🚫", + "@joinpeanut said NOT TONIGHT. guess i'll fix my whole life and come back", + 'turned away from @joinpeanut 💀 most exclusive door in crypto fr', + "couldn't get past the @joinpeanut velvet rope. humbling.", + '@joinpeanut rejection #1. framing this one.', + 'the @joinpeanut door looked me up and down and said no. respect.', + 'not on the @joinpeanut list tonight. villain arc starts now.', + "got bounced from @joinpeanut. it's giving exclusive.", + '@joinpeanut denied me with zero explanation. iconic behavior honestly', + '213 tried, 7 got in. @joinpeanut said not me. yet.', + "@joinpeanut said come back when i'm somebody. challenge accepted 🥜", + 'the @joinpeanut bouncer has no notes. just no. devastating.', + 'tried the @joinpeanut door. NOT TONIGHT. comeback arc loading.', + "@joinpeanut rejected me and i've never wanted in more 💀", + 'took an L at the @joinpeanut door tonight. tomorrow we ride again.', +] + +/** Pick a random caption. Browser-only (Math.random) — fine at share time. */ +export function pickRejectionCaption(): string { + return REJECTION_CAPTIONS[Math.floor(Math.random() * REJECTION_CAPTIONS.length)] +} diff --git a/src/components/Card/share-asset/shareAsset.types.ts b/src/components/Card/share-asset/shareAsset.types.ts index 24a9128ec..42836f066 100644 --- a/src/components/Card/share-asset/shareAsset.types.ts +++ b/src/components/Card/share-asset/shareAsset.types.ts @@ -42,6 +42,11 @@ export interface HeroMessage { tilt?: number } +/** Which smug peanut mascot to slap on the rejection ("not tonight") asset. + * 'none' hides it. cool = pixel-shades flex, mock = grinning point-and-laugh, + * chill = whistling "whatever". */ +export type RejectionMascot = 'none' | 'cool' | 'mock' | 'chill' + /** Background colour for the username pill. */ export type UsernameBg = 'white' | 'pink' | 'blue' diff --git a/src/constants/routes.ts b/src/constants/routes.ts index 095b4bc65..201477840 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -112,7 +112,7 @@ export const PUBLIC_ROUTES_REGEX = /^\/(request\/pay|claim|pay\/.+|support|invit * Only matched when IS_DEV is true */ export const DEV_ONLY_PUBLIC_ROUTES_REGEX = - /^\/(dev$|dev\/gift-test|dev\/shake-test|dev\/ds|dev\/components|dev\/share-builder)/ + /^\/(dev$|dev\/gift-test|dev\/shake-test|dev\/ds|dev\/components|dev\/share-builder|dev\/rejection-builder)/ /** * Matches locale tags with a required subtag to avoid false-positives on short From 444f48024b690f0eb26e89b849ae327b8603ab8b Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Tue, 23 Jun 2026 21:50:20 +0000 Subject: [PATCH 04/52] chore(card): silence styled-jsx eslint error on rejection asset react/no-unknown-property flags the styled-jsx `jsx` attr; scope a disable to keep the new file lint-clean, matching ShareAssetD3's pattern. --- src/components/Card/share-asset/RejectionAssetD3.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Card/share-asset/RejectionAssetD3.tsx b/src/components/Card/share-asset/RejectionAssetD3.tsx index 712258d53..58b85a570 100644 --- a/src/components/Card/share-asset/RejectionAssetD3.tsx +++ b/src/components/Card/share-asset/RejectionAssetD3.tsx @@ -87,6 +87,7 @@ const RejectionAssetD3: FC = ({ fontFamily: 'var(--font-roboto), system-ui, sans-serif', }} > + {/* eslint-disable-next-line react/no-unknown-property -- styled-jsx `jsx` attr, same pattern as ShareAssetD3 */}