diff --git a/package.json b/package.json index 79e1aa232b..d0782059b6 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "i18n-iso-countries": "^7.13.0", "iban-to-bic": "^1.4.0", "js-cookie": "^3.0.5", + "lottie-web": "^5.13.0", "lucide-react": "^1.8.0", "next": "16.2.3", "next-intl": "^4.13.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f4deb659d..811071a70d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ importers: js-cookie: specifier: ^3.0.5 version: 3.0.5 + lottie-web: + specifier: ^5.13.0 + version: 5.13.0 lucide-react: specifier: ^1.8.0 version: 1.8.0(react@19.2.4) @@ -5923,6 +5926,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lottie-web@5.13.0: + resolution: {integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -15663,6 +15669,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lottie-web@5.13.0: {} + lru-cache@10.4.3: {} lru-cache@11.2.5: {} diff --git a/src/app/(mobile-ui)/dev/components/page.tsx b/src/app/(mobile-ui)/dev/components/page.tsx index 305ebe1522..cba121d017 100644 --- a/src/app/(mobile-ui)/dev/components/page.tsx +++ b/src/app/(mobile-ui)/dev/components/page.tsx @@ -14,6 +14,8 @@ import Checkbox from '@/components/0_Bruddle/Checkbox' import CopyField from '@/components/Global/CopyField' import Loading from '@/components/Global/Loading' import PeanutLoading from '@/components/Global/PeanutLoading' +import PeanutMascot from '@/components/Global/PeanutMascot' +import type { MascotPose } from '@/components/Global/PeanutMascot/PeanutMascot.types' import ErrorAlert from '@/components/Global/ErrorAlert' import EmptyState from '@/components/Global/EmptyStates/EmptyState' import NoDataEmptyState from '@/components/Global/EmptyStates/NoDataEmptyState' @@ -35,11 +37,26 @@ const TOC: { id: string; label: string; icon: IconName }[] = [ { id: 'cards', label: 'Cards', icon: 'docs' }, { id: 'inputs', label: 'Inputs', icon: 'clip' }, { id: 'feedback', label: 'Feedback', icon: 'meter' }, + { id: 'mascot', label: 'Mascot', icon: 'smile' }, { id: 'navigation', label: 'Navigation', icon: 'link' }, { id: 'layouts', label: 'Layouts', icon: 'switch' }, { id: 'patterns', label: 'Patterns', icon: 'bulb' }, ] +// Every pose, with the screen each one leads. Keep in sync with MascotPose. +const MASCOT_POSES: { pose: MascotPose; use: string }[] = [ + { pose: 'cheering', use: 'payment / claim success' }, + { pose: 'thinking', use: 'loading, verification waits' }, + { pose: 'too-cool', use: 'passkey setup, confident flex' }, + { pose: 'pointing', use: 'waitlist, look-over-there' }, + { pose: 'sad', use: 'validation errors' }, + { pose: 'waving-hello', use: 'greetings, invites, setup' }, + { pose: 'waving-chill', use: 'landing hero, low-key wins' }, + { pose: 'walking', use: 'physical card on the way' }, + { pose: 'pointing-down', use: 'marketing CTA above a card' }, + { pose: 'worried', use: 'errors, empty states' }, +] + const ALL_ICONS: IconName[] = [ 'alert', 'alert-filled', @@ -828,6 +845,88 @@ export default function ComponentsPage() { + {/* ━━━━━━━━━━━━━━━━━━ MASCOT ━━━━━━━━━━━━━━━━━━ */} +
+
+

+ the animated mascot. ten poses, each a lottie comp loaded on demand — one pose per screen, + not all ten. plays on a shared clock, holds a static frame under prefers-reduced-motion, and + pauses when scrolled out of view. +

+ pose).join(' | '), '(required)'], + ['className', 'sizing + positioning classes', '(none)'], + ['alt', 'string — omit to make it decorative', '(none)'], + ['loop', 'boolean', 'true'], + ]} + /> +
+ sizing: give it a{' '} + height, not a square. the host takes its aspect from the + pose, so h-48 w-auto renders every pose at that height. a + square box fits the wider of the two dimensions instead, which draws a wide pose like{' '} + waving-chill ~23% shorter than a narrow one like{' '} + thinking. prefer the shared{' '} + MASCOT_HERO_CLASS /{' '} + MASCOT_STATE_CLASS over a one-off box. +
+ `} + /> +
+ +
+

+ same 96px box for every pose — the component fits the artwork, not the canvas, so they read + at a consistent size side by side. +

+
+ {MASCOT_POSES.map(({ pose, use }) => ( +
+ + {pose} + {use} +
+ ))} +
+
+ +
+

+ pass alt when the mascot carries meaning a screen reader + needs (it becomes role="img" with that label). + omit it next to copy that already says the same thing — the host goes{' '} + aria-hidden. +

+
+
+ + labelled + role="img" +
+
+ + decorative + aria-hidden +
+
+ \n`} + /> +
+
+ + + {/* ━━━━━━━━━━━━━━━━━━ NAVIGATION ━━━━━━━━━━━━━━━━━━ */}