diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx index bbc5419d8f8..bce916008ee 100644 --- a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx +++ b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx @@ -34,6 +34,7 @@ import { FunnelOrganicSignup, FunnelBrowserExtension, FunnelUploadCv, + FunnelPersonaQuiz, } from '../steps'; import { FunnelFact } from '../steps/FunnelFact'; import { FunnelCheckout } from '../steps/FunnelCheckout'; @@ -79,6 +80,7 @@ const stepComponentMap = { [FunnelStepType.PlusCards]: FunnelPlusCards, [FunnelStepType.BrowserExtension]: FunnelBrowserExtension, [FunnelStepType.UploadCv]: FunnelUploadCv, + [FunnelStepType.PersonaQuiz]: FunnelPersonaQuiz, } as const; function FunnelStepComponent(props: { diff --git a/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.module.css b/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.module.css new file mode 100644 index 00000000000..bf8b021f478 --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.module.css @@ -0,0 +1,557 @@ +/* Micro-interactions for the Patchy persona quiz. Scoped via CSS modules. */ + +@keyframes question-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes dot-blink { + 0%, + 80%, + 100% { + opacity: 0.25; + transform: scale(0.7); + } + 40% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes reveal-rise { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.questionIn { + animation: question-in 0.2s ease both; +} + +.dot { + animation: dot-blink 1.2s ease-in-out infinite; +} + +.revealName { + animation: reveal-rise 0.5s ease 0.5s both; +} + +.revealTagline { + animation: reveal-rise 0.5s ease 0.64s both; +} + +.revealActions { + animation: reveal-rise 0.5s ease 0.78s both; +} + +/* Reveal celebration. Patchy's verdict lands as an event: a glowing persona + * "amulet" springs in, a shockwave ring snaps out, and a burst of brand + * coloured sparks scatters. `--persona` (the persona's brand colour) is set + * inline on the .emblem and inherited by every layer. */ +.emblem { + position: relative; + display: grid; + place-items: center; + width: 9rem; + height: 9rem; +} + +@keyframes emblem-pop { + 0% { + transform: scale(0) rotate(-28deg); + opacity: 0; + } + 62% { + transform: scale(1.14) rotate(6deg); + opacity: 1; + } + 100% { + transform: scale(1) rotate(0); + opacity: 1; + } +} + +@keyframes emblem-glow { + 0%, + 100% { + box-shadow: 0 0 0 1px color-mix(in srgb, var(--persona) 55%, transparent), + 0 14px 44px -10px color-mix(in srgb, var(--persona) 55%, transparent); + } + 50% { + box-shadow: 0 0 0 1px color-mix(in srgb, var(--persona) 85%, transparent), + 0 18px 64px -8px color-mix(in srgb, var(--persona) 85%, transparent); + } +} + +.emblemCoin { + position: relative; + z-index: 2; + display: grid; + place-items: center; + width: 7rem; + height: 7rem; + border-radius: 9999px; + background: radial-gradient( + circle at 36% 28%, + color-mix(in srgb, var(--persona) 10%, white) 0%, + transparent 46% + ), + color-mix(in srgb, var(--persona) 32%, var(--theme-surface-float)); + border: 1px solid color-mix(in srgb, var(--persona) 65%, transparent); + animation: emblem-pop 0.6s cubic-bezier(0.18, 0.9, 0.32, 1.4) both, + emblem-glow 2.6s ease-in-out 0.65s infinite; +} + +.emblemEmoji { + font-size: 3.25rem; + line-height: 1; + filter: drop-shadow(0 3px 8px rgb(0 0 0 / 0.4)); +} + +@keyframes shockwave { + 0% { + transform: scale(0.55); + opacity: 0.85; + } + 100% { + transform: scale(2.7); + opacity: 0; + } +} + +.emblemFlash { + position: absolute; + z-index: 1; + width: 7rem; + height: 7rem; + border-radius: 9999px; + border: 2px solid color-mix(in srgb, var(--persona) 70%, transparent); + animation: shockwave 0.85s ease-out 0.1s both; +} + +/* Firework burst: glowing dots (same family as the floating dust) explode + * outward from the emblem centre, decelerate, sag a touch with gravity, then + * fade, like a firework going off where the icon is. */ +@keyframes firework { + 0% { + transform: translate(-50%, -50%) scale(0.3); + opacity: 0; + } + 12% { + opacity: 1; + } + 70% { + opacity: 1; + } + 100% { + transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) + scale(0.6); + opacity: 0; + } +} + +.fireworkSpark { + position: absolute; + left: 50%; + top: 50%; + z-index: 0; + width: var(--sw, 5px); + height: var(--sw, 5px); + border-radius: 9999px; + background: var(--sc); + box-shadow: 0 0 10px 1px color-mix(in srgb, var(--sc) 65%, transparent); + animation: firework var(--sd, 1.3s) cubic-bezier(0.12, 0.75, 0.25, 1) + var(--sdelay, 0s) both; +} + +/* White CTA sitting in the spotlight: a soft luminous halo that breathes, so + * the button reads as the lit object on the stage. Neutral white light with the + * faintest cabbage rim, so it never tints the white fill. */ +@keyframes cta-pulse { + 0%, + 100% { + box-shadow: 0 10px 34px -16px rgb(255 255 255 / 35%); + } + 50% { + box-shadow: 0 12px 42px -14px rgb(255 255 255 / 55%), + 0 0 24px -6px + color-mix(in srgb, var(--theme-accent-cabbage-default) 45%, transparent); + } +} + +@keyframes bar-shimmer { + to { + background-position: -200% 0; + } +} + +/* Primary "play" button: a living halo so it reads as the lit object on the + * stage. */ +.cta { + animation: cta-pulse 2.6s ease-in-out infinite; +} + +/* The pulse animates box-shadow, which otherwise overrides the v2 focus ring + * (also a box-shadow). Drop the halo while focused so the ring stays visible + * for keyboard users. */ +.cta:focus-visible { + animation: none; +} + +/* Frosted-glass panel for Patchy's lines: translucent, blurred, with a glass + * top-edge highlight. Replaces the flat bordered speech bubble. */ +.panel { + border-radius: 24px; + background: color-mix(in srgb, var(--theme-surface-float) 50%, transparent); + backdrop-filter: blur(18px) saturate(1.1); + -webkit-backdrop-filter: blur(18px) saturate(1.1); + border: 1px solid + color-mix(in srgb, var(--theme-text-primary) 12%, transparent); + box-shadow: 0 28px 64px -34px rgb(0 0 0 / 65%), + inset 0 1px 0 0 + color-mix(in srgb, var(--theme-text-primary) 10%, transparent); +} + +/* Thought-cloud connector: glassy circles that trail from the panel toward + * Patchy, so the message reads as coming from the avatar. */ +.bubbleDot { + border-radius: 9999px; + background: color-mix(in srgb, var(--theme-surface-float) 50%, transparent); + backdrop-filter: blur(18px) saturate(1.1); + -webkit-backdrop-filter: blur(18px) saturate(1.1); + border: 1px solid + color-mix(in srgb, var(--theme-text-primary) 12%, transparent); +} + +/* Progress fill: a slow cabbage to cheese shimmer so the bar feels alive while + * it fills, not a static block. */ +.progressFill { + background-image: linear-gradient( + 90deg, + var(--theme-accent-cabbage-default), + var(--theme-accent-bacon-default), + var(--theme-accent-cheese-default), + var(--theme-accent-cabbage-default) + ); + background-size: 200% 100%; + animation: bar-shimmer 3s linear infinite; +} + +/* Yes / No answers. Flat, no tile background or border: just a colour-filled + * circle (the icon) beside a plain label, with the labels flanking the outer + * edges. On hover the circle fills solid, glows and lifts. */ +.answer { + background: transparent; + border: none; + transition: transform 0.18s ease; +} + +/* Icon circle: a colour-tinted disc that brightens to a solid fill on hover. */ +.answerBadge { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + transition: transform 0.2s ease, background-color 0.2s ease, color 0.2s ease, + box-shadow 0.2s ease; +} + +.answerYes .answerBadge { + background: color-mix( + in srgb, + var(--theme-accent-avocado-default) 22%, + transparent + ); + color: var(--theme-accent-avocado-default); +} + +.answerNo .answerBadge { + background: color-mix( + in srgb, + var(--theme-accent-ketchup-default) 22%, + transparent + ); + color: var(--theme-accent-ketchup-default); +} + +/* Scaling the badge (not the svg) keeps the downvote's `rotate-180` intact. */ +.answerYes:hover .answerBadge { + background: var(--theme-accent-avocado-default); + color: var(--theme-surface-invert); + transform: scale(1.08); + box-shadow: 0 0 28px -4px + color-mix(in srgb, var(--theme-accent-avocado-default) 75%, transparent); +} + +.answerNo:hover .answerBadge { + background: var(--theme-accent-ketchup-default); + color: var(--theme-surface-invert); + transform: scale(1.08); + box-shadow: 0 0 28px -4px + color-mix(in srgb, var(--theme-accent-ketchup-default) 75%, transparent); +} + +/* "Not sure": a clearly secondary ghost pill, visible but lighter than the + * answer tiles. Dashed hairline + muted label that warms on hover. */ +.notSure { + border-radius: 9999px; + border: 1px dashed + color-mix(in srgb, var(--theme-text-primary) 22%, transparent); + background: color-mix(in srgb, var(--theme-surface-float) 30%, transparent); + color: var(--theme-text-secondary); + transition: transform 0.16s ease, color 0.18s ease, border-color 0.18s ease, + background-color 0.18s ease; +} + +.notSure:hover { + color: var(--theme-text-primary); + border-color: color-mix(in srgb, var(--theme-text-primary) 40%, transparent); + background: color-mix(in srgb, var(--theme-surface-float) 55%, transparent); +} + +/* Persona / option cards: a reach-for-it glow as you hover, with the emoji + * giving a little wink. */ +.card { + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.card:hover { + box-shadow: 0 14px 34px -16px + color-mix(in srgb, var(--theme-accent-cabbage-default) 90%, transparent); +} + +.cardEmoji { + transition: transform 0.2s ease; +} + +.card:hover .cardEmoji { + transform: scale(1.16) rotate(-6deg); +} + +@keyframes tick-pop { + 0% { + transform: scale(0.4); + opacity: 0; + } + 60% { + transform: scale(1.18); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +/* Selected modifier tick, popping in when the row is checked. */ +.tick { + animation: tick-pop 0.28s ease both; +} + +/* Spotlight "stage" backdrop. Decorative, scoped to the quiz step, and made + * self-contained: it paints its own opaque app background so the look is the + * same regardless of the funnel's backgroundType. A soft cabbage lamp-light + * glows up from the base, a vignette pulls focus to the centre, a colour + * shifting aurora drifts behind it, and faint twinkling dust floats up. */ +@keyframes aurora-drift { + from { + transform: translate(-58%, -3%) scale(1); + } + to { + transform: translate(-42%, 5%) scale(1.08); + } +} + +@keyframes star-twinkle { + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 0.7; + } +} + +@keyframes aurora-shift { + 0%, + 100% { + opacity: 0.25; + transform: translate(-38%, 4%) scale(1); + } + 50% { + opacity: 0.6; + transform: translate(-58%, -4%) scale(1.12); + } +} + +/* Floating "magic dust": specks lift off the bottom edge, drift up, and fade by + * ~25% of screen height. Linear timing keeps them continuously moving. */ +@keyframes particle-rise { + 0% { + transform: translateY(0) scale(0.5); + opacity: 0; + } + 12% { + opacity: 1; + } + 70% { + opacity: 0.9; + } + 100% { + transform: translateY(-26vh) scale(1); + opacity: 0; + } +} + +/* Full-bleed via `fixed` so it is never clipped by the content column, and sits + * behind the content via a negative z-index inside the isolated stage. The + * final layer is an opaque app background so the funnel gradient never bleeds + * through the transparent centre. */ +.stageBackdrop { + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + overflow: hidden; + background: radial-gradient( + 120% 95% at 50% 32%, + transparent 52%, + rgb(0 0 0 / 55%) 100% + ), + radial-gradient( + 46% 36% at 50% 104%, + color-mix(in srgb, var(--theme-accent-cabbage-default) 18%, transparent), + transparent 72% + ), + var(--theme-background-default); +} + +/* Slow-drifting aurora blob rising from the base. Gives the lamp-light life and + * a colour shift without lighting up the top of the screen. */ +.stageBackdrop::before { + content: ''; + position: absolute; + left: 50%; + bottom: -28%; + width: 90vw; + height: 60vh; + transform: translate(-50%, 0); + background: radial-gradient( + closest-side, + color-mix(in srgb, var(--theme-accent-cabbage-default) 24%, transparent), + transparent + ); + filter: blur(48px); + animation: aurora-drift 16s ease-in-out infinite alternate; +} + +/* Sparse twinkling "magic dust" starfield, masked to the stage centre. */ +.stageBackdrop::after { + content: ''; + position: absolute; + inset: 0; + background-image: radial-gradient( + 1.5px 1.5px at 18% 24%, + color-mix(in srgb, var(--theme-text-primary) 55%, transparent), + transparent + ), + radial-gradient( + 1.5px 1.5px at 72% 16%, + color-mix(in srgb, var(--theme-text-primary) 45%, transparent), + transparent + ), + radial-gradient( + 1px 1px at 84% 60%, + color-mix(in srgb, var(--theme-text-primary) 45%, transparent), + transparent + ), + radial-gradient( + 1px 1px at 30% 72%, + color-mix(in srgb, var(--theme-text-primary) 40%, transparent), + transparent + ), + radial-gradient( + 1.5px 1.5px at 58% 84%, + color-mix(in srgb, var(--theme-text-primary) 40%, transparent), + transparent + ), + radial-gradient( + 1px 1px at 8% 54%, + color-mix(in srgb, var(--theme-text-primary) 40%, transparent), + transparent + ); + animation: star-twinkle 5s ease-in-out infinite; + mask-image: radial-gradient(75% 70% at 50% 35%, #000, transparent 80%); +} + +/* Second aurora in a cooler hue, drifting the opposite way and breathing out of + * phase with the cabbage one so the spotlight slowly shifts colour. */ +.auroraAlt { + position: absolute; + left: 50%; + bottom: -30%; + width: 80vw; + height: 58vh; + background: radial-gradient( + closest-side, + color-mix(in srgb, var(--theme-accent-blueCheese-default) 26%, transparent), + transparent + ); + filter: blur(54px); + animation: aurora-shift 13s ease-in-out infinite; +} + +.magicParticle { + position: absolute; + bottom: 0; + width: 4px; + height: 4px; + border-radius: 9999px; + background: color-mix(in srgb, var(--theme-accent-cabbage-default) 75%, white); + box-shadow: 0 0 8px 1px + color-mix(in srgb, var(--theme-accent-cabbage-default) 60%, transparent); + animation: particle-rise var(--particle-duration, 6s) linear infinite; + animation-delay: var(--particle-delay, 0s); +} + +@media (prefers-reduced-motion: reduce) { + .questionIn, + .dot, + .revealName, + .revealTagline, + .revealActions, + .cta, + .progressFill, + .stageBackdrop::before, + .stageBackdrop::after, + .auroraAlt, + .magicParticle, + .emblemCoin, + .emblemFlash, + .tick { + animation: none; + } + + .magicParticle, + .emblemFlash, + .fireworkSpark { + display: none; + } + + /* Snap every remaining hover/press transition instantly (card lift, emoji + * wink, chip and badge hover, button scale) so no motion is perceived. Only + * the duration is zeroed, so transforms still apply for layout/positioning. */ + .stage *, + .stage *::before, + .stage *::after { + transition-duration: 0.01ms !important; + } +} diff --git a/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx b/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx new file mode 100644 index 00000000000..1e89f13ed2d --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx @@ -0,0 +1,1211 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; +import type { FunnelStepPersonaQuiz } from '../types/funnel'; +import { FunnelStepTransitionType } from '../types/funnel'; +import { withIsActiveGuard } from '../shared/withActiveGuard'; +import { + ButtonSize, + ButtonV2, + ButtonVariant, +} from '../../../components/buttons/ButtonV2'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { DownvoteIcon, UpvoteIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { usePersonaQuiz } from './persona/usePersonaQuiz'; +import type { AnswerValue } from './persona/engine'; +import type { DeveloperPersona } from './persona/data'; +import styles from './FunnelPersonaQuiz.module.css'; +import useTagAndSource from '../../../hooks/useTagAndSource'; +import useFeedSettings from '../../../hooks/useFeedSettings'; +import { LogEvent, Origin } from '../../../lib/log'; +import { useToastNotification } from '../../../hooks/useToastNotification'; +import { useLogContext } from '../../../contexts/LogContext'; +import { FunnelTargetId } from '../types/funnelEvents'; + +// Fallback until a mascot video is provided via parameters. +const MASCOT_EMOJI = '🧞'; + +const MASCOT_GLOW = 'drop-shadow(0 0 40px rgba(192,132,252,.45))'; + +type MascotSize = 'sm' | 'md' | 'lg'; + +const MASCOT_VIDEO_SIZE: Record = { + sm: 'h-32 w-32', + md: 'h-48 w-48', + // Patchy stays compact on mobile so the bubble and answers keep room, then + // grows to full size beside the content on laptop. + lg: 'h-32 w-32 tablet:h-40 tablet:w-40 laptop:h-96 laptop:w-96', +}; + +const MASCOT_EMOJI_SIZE: Record = { + sm: 'text-6xl', + md: 'text-[6rem]', + lg: 'text-7xl tablet:text-8xl laptop:text-[12rem]', +}; + +// Patchy sits above the bubble on mobile and to its right on laptop. +const MASCOT_STAGE_CLASS = 'order-first shrink-0 laptop:order-none'; + +type MascotState = + | 'thinking' + | 'reveal' + | 'unsure' + | 'onpath' + | 'idle1' + | 'idle2'; + +// How long Patchy rests before a random idle clip plays, and between idles. +const IDLE_MIN_DELAY_MS = 2600; +const IDLE_MAX_DELAY_MS = 6500; + +const randomBetween = (min: number, max: number): number => + min + Math.random() * (max - min); + +const prefersReducedMotion = (): boolean => + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +interface MascotProps { + /** Base path; each clip resolves to `${baseUrl}-${state}.webm` (+ .mov). */ + baseUrl?: string; + /** All clips to mount and preload, so switching never re-decodes/flashes. */ + clips?: MascotState[]; + /** The clip Patchy rests on; also the one replayed on the `playing` beat. */ + activeClip?: MascotState; + /** Idle fillers played at random while Patchy is otherwise at rest. */ + idleClips?: MascotState[]; + size?: MascotSize; + /** Replays the active clip whenever it flips true (e.g. the thinking beat). */ + playing?: boolean; + /** Playback speed; the thinking clip runs faster, the rest at natural speed. */ + playbackRate?: number; + /** Fires once the active clip passes its halfway point. */ + onHalfway?: () => void; + className?: string; +} + +const Mascot = ({ + baseUrl, + clips = ['thinking'], + activeClip = clips[0], + idleClips, + size = 'md', + playing = false, + playbackRate = 1, + onHalfway, + className, +}: MascotProps): ReactElement => { + const videoRefs = useRef>>({}); + // The clip currently animating, or null when Patchy rests. Idle scheduling + // keys off this being null. + const [playingClip, setPlayingClip] = useState(null); + // What's actually painted. It only advances once a clip is truly rendering + // (its `playing` event), so a swap never reveals a blank/seeking frame — + // which, with alpha clips, shows through as a flicker. + const [shownClip, setShownClip] = useState(activeClip); + + // Mount every primary and idle clip so swaps never re-decode or flash black. + const mountedClips = useMemo( + () => Array.from(new Set([...clips, ...(idleClips ?? [])])), + [clips, idleClips], + ); + + const play = useCallback( + (clip: MascotState) => { + const video = videoRefs.current[clip]; + if (!video) { + return; + } + // Only one clip animates at a time; pausing the rest also stops their + // `ended` from later clobbering the active clip's state. + Object.entries(videoRefs.current).forEach(([key, other]) => { + if (key !== clip && other && !other.paused) { + other.pause(); + } + }); + video.currentTime = 0; + video.playbackRate = clip === activeClip ? playbackRate : 1; + setPlayingClip(clip); + video.play().catch(() => undefined); + }, + [activeClip, playbackRate], + ); + + // Entry animation: play the active clip once when Patchy first appears. + useEffect(() => { + if (!baseUrl) { + return; + } + play(activeClip); + // Mount-only; later replays go through the `playing` effect below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [baseUrl]); + + // Replay the active clip whenever `playing` re-arms (e.g. each answer). + useEffect(() => { + if (!baseUrl || !playing) { + return; + } + play(activeClip); + }, [baseUrl, playing, activeClip, play]); + + // While Patchy rests, occasionally play a random idle clip. + useEffect(() => { + if ( + !baseUrl || + playingClip !== null || + !idleClips?.length || + prefersReducedMotion() + ) { + return undefined; + } + const timeout = setTimeout( + () => play(idleClips[Math.floor(Math.random() * idleClips.length)]), + randomBetween(IDLE_MIN_DELAY_MS, IDLE_MAX_DELAY_MS), + ); + return () => clearTimeout(timeout); + }, [baseUrl, playingClip, idleClips, play]); + + // Swap the visible clip only once it has begun rendering frames. + const handlePlaying = (clip: MascotState): void => { + setShownClip(clip); + }; + + const handleEnded = (clip: MascotState): void => { + // Keep the ended clip's last frame on screen until the next one renders. + setPlayingClip((current) => (current === clip ? null : current)); + }; + + const handleTimeUpdate = ( + event: React.SyntheticEvent, + ): void => { + const video = event.currentTarget; + if ( + onHalfway && + video.duration && + video.currentTime >= video.duration / 2 + ) { + onHalfway(); + } + }; + + if (!baseUrl) { + return ( + + {MASCOT_EMOJI} + + ); + } + + return ( +
+ {mountedClips.map((clip) => ( + + ))} +
+ ); +}; + +// Clips the in-quiz mascot can swap between; all preloaded to avoid flashes. +const QUIZ_CLIPS: MascotState[] = ['thinking', 'onpath', 'unsure']; + +// Random idle fillers played whenever Patchy is waiting on the user. +const IDLE_CLIPS: MascotState[] = ['idle1', 'idle2']; + +const THINKING_DOT_DELAYS = [0, 0.16, 0.32]; + +const ThinkingDots = (): ReactElement => ( + + {THINKING_DOT_DELAYS.map((delay) => ( + + ))} + +); + +const warmthLabelFor = (value: number): string => { + if (value >= 0.8) { + return 'red hot'; + } + if (value >= 0.5) { + return 'heating up'; + } + if (value >= 0.2) { + return 'getting warmer'; + } + return 'just getting started'; +}; + +// Phrases the result as something Patchy says, e.g. "You're a Backend +// Developer". Personas already prefixed with "The" keep their article. +const personaRevealPhrase = (name: string): string => { + if (name.startsWith('The ')) { + return `You're the ${name.slice(4)}`; + } + const article = /^[aeiou]/i.test(name) ? 'an' : 'a'; + return `You're ${article} ${name}`; +}; + +const SpeechBubble = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}): ReactElement => ( +
+ {children} + {/* Thought-cloud trailing toward Patchy: up on mobile (he sits above the + * bubble), out to the right on laptop (he sits beside it). */} + + + + + + + + + + +
+); + +// Fixed configs (left column / size / timing) so the floating dust is stable +// across SSR and re-renders instead of jumping on every paint. Delays are +// negative so each speck starts partway through its rise: at load the dust is +// already spread up the screen instead of bunched at the bottom edge. +const MAGIC_PARTICLES = [ + { left: '10%', size: 3, delay: '-2.5s', duration: '5s' }, + { left: '22%', size: 5, delay: '-1.6s', duration: '6.2s' }, + { left: '34%', size: 3, delay: '-3.2s', duration: '4.6s' }, + { left: '44%', size: 4, delay: '-0.8s', duration: '5.6s' }, + { left: '52%', size: 6, delay: '-2.4s', duration: '6.8s' }, + { left: '61%', size: 3, delay: '-4s', duration: '5.2s' }, + { left: '70%', size: 5, delay: '-0.4s', duration: '6s' }, + { left: '79%', size: 4, delay: '-3s', duration: '4.4s' }, + { left: '88%', size: 3, delay: '-1.2s', duration: '6.4s' }, + { left: '16%', size: 4, delay: '-3.8s', duration: '5.4s' }, + { left: '66%', size: 3, delay: '-4.6s', duration: '4.8s' }, + { left: '93%', size: 5, delay: '-2s', duration: '7s' }, +]; + +// Decorative spotlight-stage layer: a colour-shifting aurora plus floating +// "magic dust" rising from the lamp. Purely presentational, behind content. +const StageBackdrop = (): ReactElement => ( +
+ + {MAGIC_PARTICLES.map((particle) => ( + + ))} +
+); + +interface QuizStageProps { + /** When set, the progress header is shown; otherwise it stays in the DOM but + * invisible so the intro/reveal screens don't shift relative to questions. */ + progress?: { questionNumber: number; value: number }; + mascot: ReactNode; + children: ReactNode; +} + +// Shared skeleton for the intro, question and reveal screens: a top progress +// header (reserved on every screen) above the bubble + Patchy. On mobile Patchy +// stacks on top with actions anchored to the bottom thumb zone; on laptop they +// sit side by side as a centered row. +const QuizStage = ({ + progress, + mascot, + children, +}: QuizStageProps): ReactElement => ( +
+
+
+ + Question {progress?.questionNumber ?? 1} + + + {warmthLabelFor(progress?.value ?? 0)} + +
+
+
+
+
+
+ {children} + {mascot} +
+
+); + +type PersonaCardSize = 'medium' | 'small'; + +interface PersonaCardProps { + persona: DeveloperPersona; + onSelect: (personaId: string) => void; + size?: PersonaCardSize; +} + +const PersonaCard = ({ + persona, + onSelect, + size = 'medium', +}: PersonaCardProps): ReactElement => ( + +); + +// Reveal firework: glowing dots (same family as the floating dust) explode +// radially from the emblem centre. Generated once (deterministic, no +// randomness) so it's SSR-stable. +const FIREWORK_COLORS = [ + 'var(--theme-accent-cabbage-default)', + 'var(--theme-accent-cabbage-bolder)', + 'var(--theme-accent-cabbage-subtler)', + 'var(--theme-accent-onion-default)', + 'var(--theme-accent-onion-bolder)', + 'var(--theme-accent-onion-subtler)', +]; + +const REVEAL_FIREWORK = Array.from({ length: 30 }, (_, index) => { + // Even radial spread + slight per-spoke jitter so it reads organic. + const angle = (index / 30) * Math.PI * 2 + (index % 2 ? 0.12 : -0.12); + const distance = 95 + (index % 4) * 48; // 95 to 239px rings + const gravity = 24 + (index % 3) * 16; // gentle downward sag + return { + id: index, + dx: `${Math.round(Math.cos(angle) * distance)}px`, + dy: `${Math.round(Math.sin(angle) * distance + gravity)}px`, + sw: `${4 + (index % 3) * 2}px`, + sc: FIREWORK_COLORS[index % FIREWORK_COLORS.length], + sd: `${1.1 + (index % 4) * 0.2}s`, + sdelay: `${(index % 3) * 0.05}s`, + }; +}); + +// The celebratory persona "amulet" for the reveal: a glowing coin in the +// persona's brand colour with a shockwave ring, and a firework of glowing dots +// that explodes outward from the icon. `--persona` cascades to every layer. +const PersonaEmblem = ({ + persona, +}: { + persona: DeveloperPersona; +}): ReactElement => ( +
+ + {REVEAL_FIREWORK.map((spark) => ( + + ))} + + {persona.emoji} + +
+); + +function PersonaQuizPhases({ + parameters: { headline, explainer, cta, mascotVideoBaseUrl }, + onTransition, +}: FunnelStepPersonaQuiz): ReactElement { + const { + phase, + questionNumber, + questionText, + progress, + isThinking, + tiebreakPersonas, + triplebreakPersonas, + modifiers, + selectedModifierIds, + personas, + result, + isManual, + resolution, + questionsAnswered, + start, + answer, + chooseTiebreak, + pickManually, + selectPersona, + confirmPersona, + toggleModifier, + } = usePersonaQuiz(); + + const { onFollowTags } = useTagAndSource({ + origin: Origin.OnboardingPersona, + }); + // Prime the feed settings cache so the follow mutation's optimistic update + // has something to read; without it the mutation throws before reaching the + // server. Matches what the tag-selection step does. + const { feedSettings } = useFeedSettings(); + const { displayToast } = useToastNotification(); + const { logEvent } = useLogContext(); + const [isFollowing, setIsFollowing] = useState(false); + + // Which clip plays during the thinking beat, chosen per answer. + const [thinkingClip, setThinkingClip] = useState('thinking'); + // On the reveal we hold the answer back until Patchy finishes his animation. + const [revealReady, setRevealReady] = useState(false); + + useEffect(() => { + if (phase !== 'reveal') { + return undefined; + } + setRevealReady(false); + // Patchy now plays the reveal on every breakpoint, so wait for his + // animation whenever there is a video to wait for. + if (!mascotVideoBaseUrl) { + setRevealReady(true); + return undefined; + } + // Fallback in case the video never reports progress (e.g. blocked autoplay). + const timeout = setTimeout(() => setRevealReady(true), 2500); + return () => clearTimeout(timeout); + }, [phase, mascotVideoBaseUrl]); + + const handleAnswer = (value: AnswerValue) => { + if (isThinking) { + return; + } + const options: MascotState[] = + value === 1 ? ['thinking', 'onpath'] : ['thinking', 'unsure']; + setThinkingClip(options[Math.floor(Math.random() * options.length)]); + answer(value); + }; + + const completeStep = () => { + logEvent({ + event_name: LogEvent.CompletePersonaQuiz, + target_type: 'persona', + target_id: result?.persona.id, + extra: JSON.stringify({ + resolution, + confidence: isManual ? undefined : result?.confidence, + questions_answered: questionsAnswered, + modifiers: result?.modifiers ?? [], + manual: isManual, + // Played the quiz then rejected Patchy's guess, vs opted out at intro. + overridden: isManual && questionsAnswered > 0, + }), + }); + + onTransition({ + type: FunnelStepTransitionType.Complete, + details: { + persona: result?.persona.id, + modifiers: result?.modifiers ?? [], + confidence: isManual ? undefined : result?.confidence, + questions: questionsAnswered, + manual: isManual, + }, + }); + }; + + const handleComplete = async () => { + if (isFollowing) { + return; + } + + const tags = result?.tags ?? []; + if (!tags.length) { + completeStep(); + return; + } + + // Seeding the feed from the persona is the whole point of the quiz, so a + // failed follow must block the step. We surface it and let the user retry + // rather than dropping them into an empty feed. + setIsFollowing(true); + try { + // The follow mutation optimistically reads the feed settings cache, so it + // must be loaded first. If the user finished before the fetch resolved, + // surface the retry rather than letting the mutation throw internally. + if (!feedSettings) { + throw new Error('Feed settings are not ready yet'); + } + const { successful } = await onFollowTags({ tags, requireLogin: true }); + if (!successful) { + throw new Error('Persona tag follow was unsuccessful'); + } + completeStep(); + } catch { + displayToast( + "We couldn't set up your feed from your answers. Please try again.", + { + action: { copy: 'Retry', onClick: handleComplete }, + }, + ); + } finally { + setIsFollowing(false); + } + }; + + if (phase === 'intro') { + return ( + + } + > +
+ +
+ + {headline || 'Let me figure out your dev type'} + + + {explainer || + "A few yes/no questions, and I'll fill your feed with the stuff that's actually relevant to you."} + +
+
+
+ + {cta || "I'm ready!"} + + + Nah, I'll pick myself + +
+
+
+ ); + } + + if (phase === 'picker') { + return ( +
+ + Who are you, really? + + + Pick your type. Patchy will pretend it knew all along. + +
+ {personas.map((persona) => ( + + ))} +
+
+ ); + } + + if (phase === 'tiebreak') { + return ( + + } + > +
+ +
+ + I'm torn between these two. + + + Which one feels more like you? + +
+
+
+ {tiebreakPersonas.map((persona) => ( + + ))} +
+
+
+ ); + } + + if (phase === 'triplebreak') { + return ( + + } + > +
+ +
+ + You're a tough one. Could be any of these three. + + + Pick the one that fits best. + +
+
+
+ {triplebreakPersonas.map((persona) => ( + + ))} +
+ + None of these. Let me pick. + +
+
+ ); + } + + if (phase === 'modifiers' && result) { + return ( + + } + > +
+ +
+ + One more thing. + + + Check any of these that describe you. They tune your feed beyond + your persona. + +
+
+
+ {modifiers.map((modifier) => { + const checked = selectedModifierIds.includes(modifier.id); + return ( + + ); + })} +
+ + {selectedModifierIds.length === 0 + ? 'None of these, continue' + : 'Continue →'} + +
+
+ ); + } + + if (phase === 'reveal' && result) { + const { persona } = result; + return ( + setRevealReady(true)} + className={MASCOT_STAGE_CLASS} + /> + } + > +
+ {revealReady && ( + <> + + + {personaRevealPhrase(persona.name)} + + + {persona.tagline} + +
+ + {cta || "Yes, that's me!"} + + + Nah, I'll pick myself + +
+ + )} +
+
+ ); + } + + return ( + + } + > +
+ + + {questionText} + + +
+
+
+ + +
+ +
+
+ {isThinking && ( +
+ +
+ )} +
+
+ ); +} + +function FunnelPersonaQuizComponent( + props: FunnelStepPersonaQuiz, +): ReactElement { + // The spotlight stage lives above every phase so its animation runs + // continuously across the step. Phases remount on transition (keyed), so a + // backdrop nested inside them would restart its animation each time. + return ( +
+ + +
+ ); +} + +export const FunnelPersonaQuiz = withIsActiveGuard(FunnelPersonaQuizComponent); diff --git a/packages/shared/src/features/onboarding/steps/index.ts b/packages/shared/src/features/onboarding/steps/index.ts index a15f695eeb7..f00f5cf762d 100644 --- a/packages/shared/src/features/onboarding/steps/index.ts +++ b/packages/shared/src/features/onboarding/steps/index.ts @@ -12,3 +12,4 @@ export { FunnelPlusCards } from './FunnelPlusCards'; export { FunnelOrganicCheckout } from './FunnelOrganicCheckout'; export { FunnelBrowserExtension } from './FunnelBrowserExtension'; export { FunnelUploadCv } from './FunnelUploadCv'; +export { FunnelPersonaQuiz } from './FunnelPersonaQuiz'; diff --git a/packages/shared/src/features/onboarding/steps/persona/data.ts b/packages/shared/src/features/onboarding/steps/persona/data.ts new file mode 100644 index 00000000000..90d6996c545 --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/persona/data.ts @@ -0,0 +1,458 @@ +export interface DeveloperPersona { + /** Stable identifier used when reporting the result forward. */ + id: string; + name: string; + emoji: string; + /** Brand color for the persona, used for glow/silhouette tinting. */ + color: string; + tagline: string; + /** + * daily.dev keyword slugs batch-followed when the quiz lands on this + * persona, seeding the feed. Must be real keyword slugs to take effect. + */ + tags: string[]; +} + +export interface PersonaQuestion { + text: string; + layer: number; + lockPersonaId?: string; + exclusiveGroup?: string; + /** + * Groups closed when this question is answered yes. Encodes + * implications across groups (e.g. Q2 "you're backend" = yes + * closes primary-platform, because backend rules out web/mobile + * as the main output). + */ + closesOnYes?: string[]; + /** + * Groups closed when this question is answered no. Symmetric to + * closesOnYes for negative implications (e.g. Q1 "you ship UI" + * = no also closes primary-platform). + */ + closesOnNo?: string[]; +} + +export interface PersonaModifier { + id: string; + label: string; + description: string; + /** + * Extra keyword slugs followed on top of the persona's tags when this + * modifier is selected. Must be real keyword slugs to take effect. + */ + tags: string[]; +} + +export interface PersonaEngineConfig { + confidenceThreshold: number; + tiebreakThreshold: number; + tiebreakMargin: number; + triplebreakFloor: number; + fallbackFloor: number; + fallbackPersonaId: string; + maxQuestions: number; + minQuestions: number; + instantLockThreshold: number; + instantLockMargin: number; +} + +export const PERSONAS: DeveloperPersona[] = [ + { + id: 'generalist-developer', + name: 'Generalist Developer', + emoji: '🐙', + color: '#f59e0b', + tagline: + 'You dip into JavaScript, Python, and whatever open source is shipping this week.', + tags: ['webdev', 'javascript', 'python', 'open-source', 'github'], + }, + { + id: 'full-stack-web-developer', + name: 'Full-Stack Web Developer', + emoji: '⚛️', + color: '#06b6d4', + tagline: + 'You ship the whole thing, from the React on top to the Node underneath.', + tags: ['javascript', 'typescript', 'react', 'nodejs', 'css', 'nextjs'], + }, + { + id: 'frontend-specialist', + name: 'Frontend Specialist', + emoji: '🎨', + color: '#ec4899', + tagline: + 'You sweat the pixels, the components, and every framework war worth having.', + tags: ['react', 'typescript', 'css', 'nextjs', 'tailwind-css', 'svelte'], + }, + { + id: 'ai-specialist', + name: 'AI Specialist', + emoji: '🤖', + color: '#22c55e', + tagline: 'You build with LLMs and agents, not just around them.', + tags: ['llm', 'ai-agents', 'claude', 'openai', 'rag', 'prompt-engineering'], + }, + { + id: 'ml-engineer', + name: 'ML Engineer', + emoji: '🧠', + color: '#10b981', + tagline: 'You train the models everyone else just calls.', + tags: [ + 'machine-learning', + 'deep-learning', + 'pytorch', + 'computer-vision', + 'nlp', + 'data-science', + ], + }, + { + id: 'backend-developer', + name: 'Backend Developer', + emoji: '🛠️', + color: '#3b82f6', + tagline: 'You make the data move through APIs, queues, and databases.', + tags: ['backend', 'postgresql', 'golang', 'sql', 'nodejs', 'redis'], + }, + { + id: 'software-architect', + name: 'Software Architect', + emoji: '🏛️', + color: '#14b8a6', + tagline: 'You draw the boxes and trade-offs that everyone else builds.', + tags: [ + 'architecture', + 'microservices', + 'distributed-systems', + 'design-patterns', + 'backend', + ], + }, + { + id: 'systems-programmer', + name: 'Systems Programmer', + emoji: '⚡', + color: '#f97316', + tagline: + 'You live close to the metal in Go, Rust, C++, and Zig, fast by default.', + tags: ['rust', 'golang', 'c++', 'c', 'zig', 'performance'], + }, + { + id: 'devops-engineer', + name: 'DevOps Engineer', + emoji: '🐳', + color: '#0ea5e9', + tagline: + 'You keep prod breathing with Kubernetes, pipelines, and observability.', + tags: ['kubernetes', 'docker', 'aws', 'cicd', 'observability', 'terraform'], + }, + { + id: 'php-developer', + name: 'PHP Developer', + emoji: '🐘', + color: '#777bb3', + tagline: "You keep the web's quiet workhorse running on PHP.", + tags: ['php', 'laravel', 'symfony', 'wordpress', 'mysql'], + }, + { + id: 'security-engineer', + name: 'Security Engineer', + emoji: '🛡️', + color: '#dc2626', + tagline: 'You break it before they do, hunting CVEs and attack surface.', + tags: [ + 'security', + 'cyber', + 'vulnerability', + 'appsec', + 'authentication', + 'cryptography', + ], + }, + { + id: 'dotnet-developer', + name: '.NET Developer', + emoji: '🟦', + color: '#512bd4', + tagline: 'You live deep in the Microsoft stack, from C# to Azure.', + tags: ['.net', 'aspnet', 'blazor', 'azure', 'visual-studio'], + }, + { + id: 'game-developer', + name: 'Game Developer', + emoji: '🎮', + color: '#a855f7', + tagline: 'You chase sixty frames a second, one render loop at a time.', + tags: [ + 'game-development', + 'unity', + 'unreal-engine', + 'godot', + 'game-design', + ], + }, + { + id: 'mobile-developer', + name: 'Mobile Developer', + emoji: '📱', + color: '#f43f5e', + tagline: "You ship code straight into people's pockets.", + tags: ['android', 'ios', 'swift', 'kotlin', 'flutter', 'react-native'], + }, + { + id: 'operator', + name: 'The Operator', + emoji: '💼', + color: '#64748b', + tagline: + 'You ship outcomes, not commits, across product, design, and strategy.', + tags: [ + 'product-management', + 'startup', + 'leadership', + 'ux', + 'ui-design', + 'productivity', + ], + }, +]; + +export const QUESTIONS: PersonaQuestion[] = [ + { + text: 'You ship the things users see and click on.', + layer: 0, + exclusiveGroup: 'primary-domain', + closesOnNo: ['primary-platform'], + }, + { + text: 'Your work is mostly backend or infrastructure.', + layer: 0, + exclusiveGroup: 'primary-domain', + closesOnYes: ['primary-platform'], + }, + { + text: 'Coding is a small part of your role.', + layer: 0, + lockPersonaId: 'operator', + }, + { + text: 'Your main output is a web app people open in a browser.', + layer: 1, + exclusiveGroup: 'primary-platform', + }, + { text: "You're faster in a terminal than in any GUI.", layer: 1 }, + { + text: 'Your daily IDE is Xcode or Android Studio.', + layer: 1, + lockPersonaId: 'mobile-developer', + exclusiveGroup: 'primary-platform', + }, + { + text: 'Your day is notebooks, datasets, and training runs.', + layer: 1, + lockPersonaId: 'ml-engineer', + }, + { + text: 'Your main language is TypeScript or JavaScript.', + layer: 2, + exclusiveGroup: 'main-language', + }, + { + text: 'Your main language is Python.', + layer: 2, + exclusiveGroup: 'main-language', + }, + { + text: 'Your main language is Go, Rust, or C/C++.', + layer: 2, + exclusiveGroup: 'main-language', + }, + { + text: 'Your main language is Java or Kotlin.', + layer: 2, + exclusiveGroup: 'main-language', + }, + { + text: 'You build products on top of LLMs and AI agents.', + layer: 2, + lockPersonaId: 'ai-specialist', + }, + { + text: 'Your main language is PHP.', + layer: 2, + lockPersonaId: 'php-developer', + exclusiveGroup: 'main-language', + }, + { + text: 'Your main language is C#.', + layer: 2, + lockPersonaId: 'dotnet-developer', + exclusiveGroup: 'main-language', + }, + { + text: 'Kubernetes, CI/CD, and observability are what you ship.', + layer: 3, + lockPersonaId: 'devops-engineer', + }, + { text: 'You write more SQL than CSS.', layer: 3 }, + { + text: "You've drawn boxes and arrows on a whiteboard this month.", + layer: 3, + }, + { + text: 'You go deep in one stack rather than dabbling across many.', + layer: 2, + }, + { + text: 'You build games or interactive 3D experiences.', + layer: 2, + lockPersonaId: 'game-developer', + }, + { + text: 'Your week is threat modeling, pen tests, and vulnerability triage.', + layer: 2, + lockPersonaId: 'security-engineer', + }, +]; + +export const MODIFIERS: PersonaModifier[] = [ + { + id: 'ai-heavy', + label: 'AI Heavy', + description: 'Cursor, Claude, and agents do real chunks of your work.', + tags: ['ai-coding', 'ai-assisted-development', 'vibe-coding'], + }, + { + id: 'founder', + label: 'Founder', + description: "You're shipping your own product, startup, or side bet.", + tags: ['startup', 'business', 'product-management'], + }, + { + id: 'engineering-leader', + label: 'Engineering Leader', + description: + 'You set direction and grow engineers more than you ship code.', + tags: ['leadership', 'career', 'productivity'], + }, +]; + +/** + * Deduped union of the resolved persona's tags and the tags of every selected + * modifier. This is the keyword set batch-followed when the quiz concludes. + */ +export const resolveFollowTags = ( + persona: DeveloperPersona, + selectedModifierIds: string[], +): string[] => { + const selected = new Set(selectedModifierIds); + const modifierTags = MODIFIERS.filter((modifier) => + selected.has(modifier.id), + ).flatMap((modifier) => modifier.tags); + + return Array.from(new Set([...persona.tags, ...modifierTags])); +}; + +/** + * Likelihood matrix: P[persona][question] = probability a member of that + * persona answers yes. Computed from 90d engagement data on ~92k active + * daily.dev users via K-means. The Operator row and the 'don't write code' + * column are hand-crafted (non-engineers don't appear in the clustering). + */ +export const PERSONA_QUESTION_LIKELIHOOD: number[][] = [ + [ + 0.17132, 0.274564, 0, 0.13908, 0.374098, 0, 0, 0.33229, 0.15462, 0.307211, + 0.107158, 0.00243542, 0.00205851, 0.00118872, 0.162274, 0.217303, 0.19953, + 0.0301818, 0, 0.00316024, + ], + [ + 0.956496, 0.00212214, 0, 0.960092, 0.103042, 0, 0, 0.972353, 0.0832351, + 0.19005, 0.0341311, 0.0142066, 0.00448008, 0.00135581, 0.0695001, 0.140887, + 0.110057, 0.00394954, 0, 0.000471587, + ], + [ + 1, 0, 0, 1, 0.0302971, 0, 0, 1, 0.0421441, 0.0646728, 0.011847, 0, + 0.0110701, 0.00271897, 0.0112643, 0.0471936, 0.0423383, 0.810643, 0, + 0.0102933, + ], + [ + 0.0523551, 0.0548913, 0, 0.0532609, 0.0719203, 0, 0, 0.0844203, 0.11558, + 0.0490942, 0.0106884, 0.968116, 0.0057971, 0.00271739, 0.036413, 0.0338768, + 0.0594203, 0.645109, 0, 0.00706522, + ], + [ + 0.082495, 0.126761, 0, 0.100604, 0.0704225, 0.00402414, 1, 0.104628, 1, + 0.0422535, 0.00804829, 0.0704225, 0.0100604, 0.00804829, 0.0241449, + 0.0925553, 0.0382294, 0.169014, 0, 0.00804829, + ], + [ + 0.0662522, 0.874879, 0, 0.0866524, 0.0650865, 0, 0, 0.102584, 0.0837381, + 0.0699437, 0.0248689, 0.000194288, 0.00680008, 0.00213717, 0.0316689, + 0.999029, 0.171751, 0.128424, 0, 0.00213717, + ], + [ + 0.0497947, 0.249179, 0, 0.0684805, 0.0584189, 0, 0, 0.105647, 0.119405, + 0.0734086, 0.0412731, 0.0148871, 0.0036961, 0.00174538, 0.0449692, 0.200616, + 0.444764, 0.0609856, 0, 0.00256674, + ], + [ + 0.0948818, 0.859922, 0, 0.0918887, 0.954205, 0, 0, 0.180485, 0.129003, + 0.977252, 0.0466926, 0.00209518, 0.00448967, 0.00119725, 0.134092, 0.14786, + 0.155642, 0.0583658, 0, 0.00149656, + ], + [ + 0.0669856, 0.912679, 0, 0.069378, 0.94378, 0, 0, 0.152711, 0.106858, + 0.165869, 0.0641946, 0.00518341, 0.00279107, 0.0015949, 0.961324, 0.210128, + 0.197767, 0.129585, 0, 0.0099681, + ], + [ + 0.987699, 0.146165, 0, 0.999276, 0.109986, 0, 0, 0.342619, 0.0680174, + 0.15919, 0.0231548, 0.00108538, 0.880246, 0.00144718, 0.0918958, 0.196093, + 0.0770622, 0.140738, 0, 0.00397974, + ], + [ + 0.345912, 0.562035, 0, 0.42024, 0.19211, 0, 0, 0.412807, 0.136078, + 0.0897656, 0.0125786, 0.00285878, 0.00857633, 0.00114351, 0.0423099, + 0.0691824, 0.0343053, 0.121784, 0, 0.686106, + ], + [ + 0.0816761, 0.860085, 0, 0.0802557, 0.144176, 0, 0, 0.199574, 0.0482955, + 0.15483, 0.0262784, 0, 0.00213068, 0.827415, 0.0646307, 0.182528, 0.230114, + 0.138494, 0, 0.00355114, + ], + [ + 1, 0, 0, 0.180198, 0.150495, 0.0247525, 0.00792079, 0.222772, 0.119802, + 0.148515, 0.0168317, 0.0217822, 0.0128713, 0.0237624, 0.0257426, 0.0633663, + 0.0356436, 0.179208, 1, 0.0158416, + ], + [ + 0.879567, 0, 0, 0.32544, 0.116373, 1, 0, 0.343708, 0.0818674, 0.0751015, + 0.105548, 0.0250338, 0.0216509, 0.00473613, 0.0290934, 0.102165, 0.135995, + 0.135995, 0, 0.00811908, + ], + [ + 0.2, 0.03, 0.95, 0.05, 0.02, 0.03, 0.03, 0.03, 0.05, 0.01, 0.01, 0.03, 0.01, + 0.01, 0.02, 0.05, 0.4, 0.25, 0.02, 0.03, + ], +]; + +/** Prior probability of each persona (log-shaped). */ +export const PERSONA_PRIOR: number[] = [ + 0.2262, 0.1583, 0.0724, 0.0763, 0.0092, 0.0724, 0.1133, 0.0516, 0.0407, + 0.0441, 0.0297, 0.0245, 0.018, 0.0256, 0.0377, +]; + +export const PERSONA_ENGINE_CONFIG: PersonaEngineConfig = { + confidenceThreshold: 0.75, + tiebreakThreshold: 0.5, + tiebreakMargin: 0.07, + triplebreakFloor: 0.3, + fallbackFloor: 0.12, + fallbackPersonaId: 'generalist-developer', + maxQuestions: 12, + minQuestions: 5, + instantLockThreshold: 0.85, + instantLockMargin: 0.5, +}; diff --git a/packages/shared/src/features/onboarding/steps/persona/engine.ts b/packages/shared/src/features/onboarding/steps/persona/engine.ts new file mode 100644 index 00000000000..efeea2db938 --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/persona/engine.ts @@ -0,0 +1,179 @@ +import { + PERSONAS, + PERSONA_QUESTION_LIKELIHOOD as P, + PERSONA_PRIOR, + QUESTIONS, +} from './data'; + +/** Answer weight: 1 = yes, 0 = no, 0.5 = not sure (no belief update). */ +export type AnswerValue = 0 | 0.5 | 1; + +const PERSONA_COUNT = PERSONAS.length; + +/** + * The likelihood matrix, prior, and persona/question lists are positional and + * must stay aligned. Fail fast on import so editing the data file can never + * silently change behavior (e.g. a row/column added to only one of them). + */ +const validatePersonaData = (): void => { + if (PERSONA_PRIOR.length !== PERSONA_COUNT) { + throw new Error('Persona prior must have one entry per persona.'); + } + if (P.length !== PERSONA_COUNT) { + throw new Error('Likelihood matrix must have one row per persona.'); + } + if (P.some((row) => row.length !== QUESTIONS.length)) { + throw new Error('Each likelihood row must have one entry per question.'); + } +}; +validatePersonaData(); + +export const initialBelief = (): number[] => PERSONA_PRIOR.slice(); + +/** Resolve a persona id to its index, throwing if it is not in the data. */ +export const personaIndexById = (id: string): number => { + const index = PERSONAS.findIndex((persona) => persona.id === id); + if (index < 0) { + throw new Error(`Unknown persona id: ${id}`); + } + return index; +}; + +const entropy = (belief: number[]): number => + belief.reduce((acc, p) => (p > 1e-12 ? acc - p * Math.log2(p) : acc), 0); + +/** + * Expected reduction in entropy from asking a question, given current belief. + * The engine greedily picks the question with the highest information gain. + */ +const informationGain = (belief: number[], question: number): number => { + let pYes = 0; + for (let i = 0; i < PERSONA_COUNT; i += 1) { + pYes += belief[i] * P[i][question]; + } + + if (pYes <= 1e-9 || pYes >= 1 - 1e-9) { + return 0; + } + + const beliefIfYes = belief.map((bi, i) => (bi * P[i][question]) / pYes); + const beliefIfNo = belief.map( + (bi, i) => (bi * (1 - P[i][question])) / (1 - pYes), + ); + + return ( + entropy(belief) - + (pYes * entropy(beliefIfYes) + (1 - pYes) * entropy(beliefIfNo)) + ); +}; + +/** + * Questions are gated by depth so the experience moves from broad to specific. + * Deeper layers unlock as more questions are answered. + */ +const allowedLayers = (questionsShown: number): Set => { + if (questionsShown === 0) { + return new Set([0]); + } + if (questionsShown < 3) { + return new Set([0, 1]); + } + if (questionsShown < 5) { + return new Set([0, 1, 2]); + } + return new Set([0, 1, 2, 3]); +}; + +/** + * Returns the next best question index, or -1 when none remain. + * + * Question selection is greedy on information gain, with two extra rules: + * - exclusiveGroup: once a group is closed (a yes answer to one of its + * members, or a closesOnYes/closesOnNo from elsewhere), the remaining + * members are skipped. + * - active-group preference: once any question in an open exclusiveGroup + * has been asked, prefer the remaining members before moving on. Stops + * the engine from bailing on a half-asked group when info gain on the + * leftover questions looks low in expectation but huge conditional + * on a yes (e.g. PHP and .NET locks inside the main-language group). + */ +export const pickNextQuestion = ( + belief: number[], + asked: Set, + questionsShown: number, + excludedGroups: Set = new Set(), +): number => { + const layers = allowedLayers(questionsShown); + + // Groups that have been started but aren't closed yet. + const activeGroups = new Set(); + QUESTIONS.forEach((question, q) => { + if ( + question.exclusiveGroup && + asked.has(q) && + !excludedGroups.has(question.exclusiveGroup) + ) { + activeGroups.add(question.exclusiveGroup); + } + }); + + let bestInActive = { index: -1, gain: -1 }; + let bestOverall = { index: -1, gain: -1 }; + + QUESTIONS.forEach((question, q) => { + if (asked.has(q) || !layers.has(question.layer)) { + return; + } + if ( + question.exclusiveGroup && + excludedGroups.has(question.exclusiveGroup) + ) { + return; + } + const gain = informationGain(belief, q); + if ( + question.exclusiveGroup && + activeGroups.has(question.exclusiveGroup) && + gain > bestInActive.gain + ) { + bestInActive = { index: q, gain }; + } + if (gain > bestOverall.gain) { + bestOverall = { index: q, gain }; + } + }); + + return bestInActive.index >= 0 ? bestInActive.index : bestOverall.index; +}; + +/** Bayesian update of the belief vector given an answer to a question. */ +export const updateBelief = ( + belief: number[], + question: number, + answer: AnswerValue, +): number[] => { + const next = belief.map((bi, i) => { + const pYes = P[i][question]; + const likelihood = answer * pYes + (1 - answer) * (1 - pYes); + return bi * likelihood; + }); + + const sum = next.reduce((acc, value) => acc + value, 0); + if (sum <= 0) { + return belief; + } + + return next.map((value) => value / sum); +}; + +export interface BeliefRanking { + index: number; + belief: number; +} + +export const rankBelief = (belief: number[]): BeliefRanking[] => + belief + .map((value, index) => ({ index, belief: value })) + .sort((a, b) => b.belief - a.belief); + +export const beliefEntropy = entropy; diff --git a/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts b/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts new file mode 100644 index 00000000000..bb3a441f2af --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts @@ -0,0 +1,415 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import type { AnswerValue } from './engine'; +import { + initialBelief, + personaIndexById, + pickNextQuestion, + rankBelief, + updateBelief, +} from './engine'; +import type { DeveloperPersona, PersonaModifier } from './data'; +import { + MODIFIERS, + PERSONAS, + PERSONA_ENGINE_CONFIG, + QUESTIONS, + resolveFollowTags, +} from './data'; + +export type PersonaQuizPhase = + | 'intro' + | 'playing' + | 'tiebreak' + | 'triplebreak' + | 'modifiers' + | 'picker' + | 'reveal'; + +/** How the quiz arrived at its persona, reported for analytics. */ +export type PersonaResolution = + | 'auto' + | 'fallback' + | 'tiebreak' + | 'triplebreak' + | 'manual'; + +/** UI-only pause so the belief shift feels deliberate; not a game tunable. */ +const THINKING_DURATION_MS = 450; + +const { + confidenceThreshold, + tiebreakThreshold, + tiebreakMargin, + triplebreakFloor, + fallbackFloor, + fallbackPersonaId, + maxQuestions, + minQuestions, + instantLockThreshold, + instantLockMargin, +} = PERSONA_ENGINE_CONFIG; + +const FALLBACK_PERSONA_INDEX = personaIndexById(fallbackPersonaId); + +// The prior already favors one persona, so the top belief starts well above 0. +// We rescale progress from this baseline up to the confidence threshold so the +// bar starts near-empty and uses its full range for the actual narrowing-down. +const BASELINE_TOP = Math.max(...initialBelief()); + +interface PersonaResult { + persona: DeveloperPersona; + confidence: number; + modifiers: string[]; + /** Deduped persona + selected-modifier tags to batch-follow on finish. */ + tags: string[]; +} + +export interface PersonaQuizState { + phase: PersonaQuizPhase; + belief: number[]; + questionNumber: number; + questionText: string | null; + progress: number; + isThinking: boolean; + tiebreakPersonas: DeveloperPersona[]; + triplebreakPersonas: DeveloperPersona[]; + modifiers: PersonaModifier[]; + selectedModifierIds: string[]; + personas: DeveloperPersona[]; + result: PersonaResult | null; + /** True when the user picked their persona instead of playing the quiz. */ + isManual: boolean; + /** How the persona was reached, for analytics. */ + resolution: PersonaResolution; + questionsAnswered: number; + start: () => void; + answer: (value: AnswerValue) => void; + chooseTiebreak: (personaId: string) => void; + pickManually: () => void; + selectPersona: (personaId: string) => void; + confirmPersona: () => void; + toggleModifier: (modifierId: string) => void; + restart: () => void; +} + +export const usePersonaQuiz = (): PersonaQuizState => { + const [phase, setPhase] = useState('intro'); + const [belief, setBelief] = useState(() => initialBelief()); + const [currentQuestion, setCurrentQuestion] = useState(null); + const [questionsShown, setQuestionsShown] = useState(0); + const [isThinking, setIsThinking] = useState(false); + const [tiebreak, setTiebreak] = useState(null); + const [resultIndex, setResultIndex] = useState(null); + const [isManual, setIsManual] = useState(false); + const [resolution, setResolution] = useState('auto'); + const [selectedModifierIds, setSelectedModifierIds] = useState([]); + // How close Patchy is to a confident guess (0..1), clamped so it never + // visibly moves backwards even if belief dips after a surprising answer. + const [progress, setProgress] = useState(0); + + const askedRef = useRef>(new Set()); + const excludedGroupsRef = useRef>(new Set()); + const thinkingTimeout = useRef>(); + + // Quiz paths land on the reveal first, so the user can approve Patchy's + // guess before the modifiers screen. + const revealGuess = useCallback((index: number, nextBelief: number[]) => { + setResultIndex(index); + setTiebreak(null); + setBelief(nextBelief); + setSelectedModifierIds([]); + setPhase('reveal'); + }, []); + + // Manual selection skips straight to the modifiers screen. + const goToModifiers = useCallback((index: number, nextBelief: number[]) => { + setResultIndex(index); + setTiebreak(null); + setBelief(nextBelief); + setSelectedModifierIds([]); + setPhase('modifiers'); + }, []); + + // Approve Patchy's guess from the reveal screen. + const confirmPersona = useCallback(() => { + setPhase('modifiers'); + }, []); + + const finish = useCallback( + (nextBelief: number[]) => { + const ranked = rankBelief(nextBelief); + const top = ranked[0]; + const runnerUp = ranked[1]; + const third = ranked[2]; + + // 1. Confident top-1: skip to modifiers. + if ( + top.belief >= tiebreakThreshold && + top.belief - runnerUp.belief >= tiebreakMargin + ) { + setResolution('auto'); + revealGuess(top.index, nextBelief); + return; + } + + // 2. Belief is too diffuse: fall back to the generalist. + if (top.belief < fallbackFloor) { + setResolution('fallback'); + revealGuess(FALLBACK_PERSONA_INDEX, nextBelief); + return; + } + + // 3. Belief is moderate but not decisive: two-way pick. + if (top.belief >= triplebreakFloor && runnerUp) { + setResolution('tiebreak'); + setTiebreak([top.index, runnerUp.index]); + setBelief(nextBelief); + setPhase('tiebreak'); + return; + } + + // 4. Below triplebreak floor: three-way pick. + const candidates = [top.index, runnerUp?.index, third?.index].filter( + (index): index is number => typeof index === 'number', + ); + + if (candidates.length >= 2) { + const isTriple = candidates.length >= 3; + setResolution(isTriple ? 'triplebreak' : 'tiebreak'); + setTiebreak(candidates.slice(0, 3)); + setBelief(nextBelief); + setPhase(isTriple ? 'triplebreak' : 'tiebreak'); + return; + } + + setResolution('auto'); + revealGuess(top.index, nextBelief); + }, + [revealGuess], + ); + + const advance = useCallback( + (nextBelief: number[], shownSoFar: number) => { + const ranked = rankBelief(nextBelief); + const top = ranked[0]?.belief ?? 0; + const margin = top - (ranked[1]?.belief ?? 0); + const reachedConfidence = + top >= confidenceThreshold && shownSoFar >= minQuestions; + const reachedInstantLock = + top >= instantLockThreshold && margin >= instantLockMargin; + + if ( + shownSoFar >= maxQuestions || + reachedConfidence || + reachedInstantLock + ) { + finish(nextBelief); + return; + } + + const next = pickNextQuestion( + nextBelief, + askedRef.current, + shownSoFar, + excludedGroupsRef.current, + ); + if (next < 0) { + finish(nextBelief); + return; + } + + askedRef.current.add(next); + setCurrentQuestion(next); + setQuestionsShown(shownSoFar + 1); + + // Confidence rescaled from the prior baseline → full bar range is spent + // on real narrowing, not the baseline. A per-question floor guarantees + // the bar visibly moves on every answer. + const confComponent = + (top - BASELINE_TOP) / (confidenceThreshold - BASELINE_TOP); + const countComponent = (shownSoFar + 1) / maxQuestions; + const closeness = Math.min(1, Math.max(0, confComponent, countComponent)); + setProgress((prev) => Math.max(prev, closeness)); + }, + [finish], + ); + + const start = useCallback(() => { + askedRef.current = new Set(); + excludedGroupsRef.current = new Set(); + const fresh = initialBelief(); + setBelief(fresh); + setResultIndex(null); + setTiebreak(null); + setIsThinking(false); + setIsManual(false); + setResolution('auto'); + setSelectedModifierIds([]); + setProgress(0); + setQuestionsShown(0); + setPhase('playing'); + advance(fresh, 0); + }, [advance]); + + const answer = useCallback( + (value: AnswerValue) => { + if (currentQuestion === null || isThinking) { + return; + } + + const question = QUESTIONS[currentQuestion]; + const nextBelief = updateBelief(belief, currentQuestion, value); + setBelief(nextBelief); + setIsThinking(true); + + if (value === 1 && question.exclusiveGroup) { + excludedGroupsRef.current.add(question.exclusiveGroup); + } + // closesOnYes / closesOnNo: cross-group implications. + // Example: Q2 "you're backend" = yes also closes primary-platform, + // because that rules out web/mobile as the main output. + if (value === 1 && question.closesOnYes) { + question.closesOnYes.forEach((group) => + excludedGroupsRef.current.add(group), + ); + } + if (value === 0 && question.closesOnNo) { + question.closesOnNo.forEach((group) => + excludedGroupsRef.current.add(group), + ); + } + + thinkingTimeout.current = setTimeout(() => { + setIsThinking(false); + + if (value === 1 && question.lockPersonaId) { + const lockIndex = PERSONAS.findIndex( + (persona) => persona.id === question.lockPersonaId, + ); + if (lockIndex >= 0) { + setResolution('auto'); + revealGuess(lockIndex, nextBelief); + return; + } + } + + advance(nextBelief, questionsShown); + }, THINKING_DURATION_MS); + }, + [advance, belief, currentQuestion, revealGuess, isThinking, questionsShown], + ); + + const chooseTiebreak = useCallback( + (personaId: string) => { + const index = PERSONAS.findIndex((persona) => persona.id === personaId); + if (index < 0) { + return; + } + revealGuess(index, belief); + }, + [belief, revealGuess], + ); + + const pickManually = useCallback(() => { + setResultIndex(null); + setTiebreak(null); + setIsManual(false); + setSelectedModifierIds([]); + setPhase('picker'); + }, []); + + const selectPersona = useCallback( + (personaId: string) => { + const index = PERSONAS.findIndex((persona) => persona.id === personaId); + if (index < 0) { + return; + } + setIsManual(true); + setResolution('manual'); + goToModifiers(index, belief); + }, + [belief, goToModifiers], + ); + + const toggleModifier = useCallback((modifierId: string) => { + setSelectedModifierIds((current) => { + if (current.includes(modifierId)) { + return current.filter((id) => id !== modifierId); + } + return [...current, modifierId]; + }); + }, []); + + const restart = useCallback(() => { + if (thinkingTimeout.current) { + clearTimeout(thinkingTimeout.current); + } + setPhase('intro'); + setBelief(initialBelief()); + setCurrentQuestion(null); + setQuestionsShown(0); + setIsThinking(false); + setTiebreak(null); + setResultIndex(null); + setIsManual(false); + setResolution('auto'); + setSelectedModifierIds([]); + setProgress(0); + askedRef.current = new Set(); + excludedGroupsRef.current = new Set(); + }, []); + + const tiebreakPersonas = useMemo(() => { + if (!tiebreak || phase !== 'tiebreak') { + return []; + } + return tiebreak.slice(0, 2).map((index) => PERSONAS[index]); + }, [phase, tiebreak]); + + const triplebreakPersonas = useMemo(() => { + if (!tiebreak || phase !== 'triplebreak') { + return []; + } + return tiebreak.slice(0, 3).map((index) => PERSONAS[index]); + }, [phase, tiebreak]); + + const result = useMemo(() => { + if (resultIndex === null) { + return null; + } + const persona = PERSONAS[resultIndex]; + return { + persona, + confidence: belief[resultIndex], + modifiers: selectedModifierIds, + tags: resolveFollowTags(persona, selectedModifierIds), + }; + }, [belief, resultIndex, selectedModifierIds]); + + return { + phase, + belief, + questionNumber: questionsShown, + questionText: + currentQuestion !== null ? QUESTIONS[currentQuestion].text : null, + progress, + isThinking, + tiebreakPersonas, + triplebreakPersonas, + modifiers: MODIFIERS, + selectedModifierIds, + personas: PERSONAS, + result, + isManual, + resolution, + questionsAnswered: askedRef.current.size, + start, + answer, + chooseTiebreak, + pickManually, + selectPersona, + confirmPersona, + toggleModifier, + restart, + }; +}; diff --git a/packages/shared/src/features/onboarding/types/funnel.ts b/packages/shared/src/features/onboarding/types/funnel.ts index c22766ec560..13eabbecb7a 100644 --- a/packages/shared/src/features/onboarding/types/funnel.ts +++ b/packages/shared/src/features/onboarding/types/funnel.ts @@ -31,6 +31,7 @@ export enum FunnelStepType { OrganicCheckout = 'organicCheckout', BrowserExtension = 'browserExtension', UploadCv = 'uploadCv', + PersonaQuiz = 'personaQuiz', } export enum FunnelBackgroundVariant { @@ -377,6 +378,28 @@ export interface FunnelStepUploadCv onTransition: FunnelStepTransitionCallback; } +export interface FunnelStepPersonaQuiz + extends FunnelStepCommon<{ + headline?: string; + explainer?: string; + cta?: string; + /** + * Base path for the mascot clips. The component appends the per-state + * suffix, e.g. `${base}-thinking.webm`, `${base}-reveal.webm`. An alpha + * WebM is expected, with an HEVC `.mov` sibling for Safari. + */ + mascotVideoBaseUrl?: string; + }> { + type: FunnelStepType.PersonaQuiz; + onTransition: FunnelStepTransitionCallback<{ + persona?: string; + confidence?: number; + questions: number; + manual: boolean; + modifiers: string[]; + }>; +} + export type FunnelStep = | FunnelStepLandingPage | FunnelStepFact @@ -397,7 +420,8 @@ export type FunnelStep = | FunnelStepOrganicCheckout | FunnelStepBrowserExtension | FunnelStepPlusCards - | FunnelStepUploadCv; + | FunnelStepUploadCv + | FunnelStepPersonaQuiz; export type FunnelPosition = { chapter: number; @@ -446,4 +470,5 @@ export const stepsFullWidth: Array = [ FunnelStepType.BrowserExtension, FunnelStepType.InstallPwa, FunnelStepType.UploadCv, + FunnelStepType.PersonaQuiz, ]; diff --git a/packages/shared/src/features/onboarding/types/funnelEvents.ts b/packages/shared/src/features/onboarding/types/funnelEvents.ts index 395c10590f1..ea7c4677a9e 100644 --- a/packages/shared/src/features/onboarding/types/funnelEvents.ts +++ b/packages/shared/src/features/onboarding/types/funnelEvents.ts @@ -27,6 +27,9 @@ export enum FunnelTargetId { FeedTag = 'feed tag', FeedPreview = 'feed preview', FeedContentType = 'feed content type', + PersonaSelect = 'persona select', + PersonaModifier = 'persona modifier', + PersonaManualPick = 'persona manual pick', } export type FunnelEvent = diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 2b020ae1fa2..5a1a989eaaa 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -487,6 +487,7 @@ export enum LogEvent { ReaderEmbedError = 'reader embed error', // Onboarding personas SelectOnboardingPersona = 'select onboarding persona', + CompletePersonaQuiz = 'complete persona quiz', // Extension activation primer ExtensionPrimerShown = 'impression extension primer', ExtensionPrimerCtaClick = 'click extension primer cta', diff --git a/packages/webapp/public/onboarding/patchy-idle1.mov b/packages/webapp/public/onboarding/patchy-idle1.mov new file mode 100644 index 00000000000..f25ed083be7 Binary files /dev/null and b/packages/webapp/public/onboarding/patchy-idle1.mov differ diff --git a/packages/webapp/public/onboarding/patchy-idle1.webm b/packages/webapp/public/onboarding/patchy-idle1.webm new file mode 100644 index 00000000000..a28860c59c8 Binary files /dev/null and b/packages/webapp/public/onboarding/patchy-idle1.webm differ diff --git a/packages/webapp/public/onboarding/patchy-idle2.mov b/packages/webapp/public/onboarding/patchy-idle2.mov new file mode 100644 index 00000000000..6d5c0c64cf2 Binary files /dev/null and b/packages/webapp/public/onboarding/patchy-idle2.mov differ diff --git a/packages/webapp/public/onboarding/patchy-idle2.webm b/packages/webapp/public/onboarding/patchy-idle2.webm new file mode 100644 index 00000000000..3365a938ac9 Binary files /dev/null and b/packages/webapp/public/onboarding/patchy-idle2.webm differ diff --git a/packages/webapp/public/onboarding/patchy-onpath.mov b/packages/webapp/public/onboarding/patchy-onpath.mov new file mode 100644 index 00000000000..99f9f069497 Binary files /dev/null and b/packages/webapp/public/onboarding/patchy-onpath.mov differ diff --git a/packages/webapp/public/onboarding/patchy-onpath.webm b/packages/webapp/public/onboarding/patchy-onpath.webm new file mode 100644 index 00000000000..958370a0c1f Binary files /dev/null and b/packages/webapp/public/onboarding/patchy-onpath.webm differ diff --git a/packages/webapp/public/onboarding/patchy-reveal.mov b/packages/webapp/public/onboarding/patchy-reveal.mov new file mode 100644 index 00000000000..68e2c0afbf2 Binary files /dev/null and b/packages/webapp/public/onboarding/patchy-reveal.mov differ diff --git a/packages/webapp/public/onboarding/patchy-reveal.webm b/packages/webapp/public/onboarding/patchy-reveal.webm new file mode 100644 index 00000000000..bf11c6eb2b0 Binary files /dev/null and b/packages/webapp/public/onboarding/patchy-reveal.webm differ diff --git a/packages/webapp/public/onboarding/patchy-thinking.mov b/packages/webapp/public/onboarding/patchy-thinking.mov new file mode 100644 index 00000000000..826b765f139 Binary files /dev/null and b/packages/webapp/public/onboarding/patchy-thinking.mov differ diff --git a/packages/webapp/public/onboarding/patchy-thinking.webm b/packages/webapp/public/onboarding/patchy-thinking.webm new file mode 100644 index 00000000000..ceed78656cb Binary files /dev/null and b/packages/webapp/public/onboarding/patchy-thinking.webm differ diff --git a/packages/webapp/public/onboarding/patchy-unsure.mov b/packages/webapp/public/onboarding/patchy-unsure.mov new file mode 100644 index 00000000000..ef93bf43a6b Binary files /dev/null and b/packages/webapp/public/onboarding/patchy-unsure.mov differ diff --git a/packages/webapp/public/onboarding/patchy-unsure.webm b/packages/webapp/public/onboarding/patchy-unsure.webm new file mode 100644 index 00000000000..01d54cb0bee Binary files /dev/null and b/packages/webapp/public/onboarding/patchy-unsure.webm differ