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..1b00cc35f66 --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.module.css @@ -0,0 +1,64 @@ +/* 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.15s both; +} + +.revealTagline { + animation: reveal-rise 0.5s ease 0.3s both; +} + +.revealActions { + animation: reveal-rise 0.5s ease 0.45s both; +} + +@media (prefers-reduced-motion: reduce) { + .questionIn, + .dot, + .revealName, + .revealTagline, + .revealActions { + animation: none; + } +} 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..5187b251259 --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx @@ -0,0 +1,946 @@ +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 { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { DownvoteIcon, UpvoteIcon } from '../../../components/icons'; +import { usePersonaQuiz } from './persona/usePersonaQuiz'; +import type { AnswerValue } from './persona/engine'; +import type { DeveloperPersona } from './persona/data'; +import styles from './FunnelPersonaQuiz.module.css'; + +// 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 'almost got it'; + } + if (value >= 0.5) { + return 'narrowing it down'; + } + 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} + {/* On mobile Patchy sits above the bubble, so the tail points up. */} + + {/* On laptop Patchy sits to the right, so the tail points right. */} + +
+); + +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 => ( + +); + +function FunnelPersonaQuizComponent({ + parameters: { headline, explainer, cta, mascotVideoBaseUrl }, + onTransition, +}: FunnelStepPersonaQuiz): ReactElement { + const { + phase, + questionNumber, + questionText, + progress, + isThinking, + tiebreakPersonas, + triplebreakPersonas, + modifiers, + selectedModifierIds, + personas, + result, + isManual, + questionsAnswered, + start, + answer, + chooseTiebreak, + pickManually, + selectPersona, + confirmPersona, + toggleModifier, + } = usePersonaQuiz(); + + // 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 handleComplete = () => { + onTransition({ + type: FunnelStepTransitionType.Complete, + details: { + persona: result?.persona.id, + modifiers: result?.modifiers ?? [], + confidence: isManual ? undefined : result?.confidence, + questions: questionsAnswered, + manual: isManual, + }, + }); + }; + + if (phase === 'intro') { + return ( + + } + > +
+ +
+ + {headline || "Let's play a game"} + + + {explainer || + 'A few quick yes/no questions are enough for me to know what kind of dev you are.'} + +
+
+
+ + +
+
+
+ ); + } + + 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) => ( + + ))} +
+ +
+
+ ); + } + + if (phase === 'modifiers' && result) { + return ( + + } + > +
+ +
+ + One more thing. + + + Tick any of these that describe you. They tune your feed beyond + your persona. + +
+
+
+ {modifiers.map((modifier) => { + const checked = selectedModifierIds.includes(modifier.id); + return ( + + ); + })} +
+ +
+
+ ); + } + + if (phase === 'reveal' && result) { + const { persona } = result; + return ( + setRevealReady(true)} + className={MASCOT_STAGE_CLASS} + /> + } + > +
+ {revealReady && ( + <> + +
+ + {personaRevealPhrase(persona.name)} {persona.emoji} + + + {persona.tagline} + +
+
+
+ + +
+ + )} +
+
+ ); + } + + return ( + + } + > +
+ + + {questionText} + + +
+
+
+ + +
+ +
+
+ {isThinking && ( +
+ +
+ )} +
+
+ ); +} + +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..d69aa596125 --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/persona/data.ts @@ -0,0 +1,357 @@ +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; +} + +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; + emoji: string; + description: 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: + "Your curiosity is too broad to pin down. We'll give you a wide-angle feed.", + }, + { + id: 'full-stack-web-developer', + name: 'Full-Stack Web Developer', + emoji: '⚛️', + color: '#06b6d4', + tagline: 'React, TypeScript, Node, the works. You ship product.', + }, + { + id: 'frontend-specialist', + name: 'Frontend Specialist', + emoji: '🎨', + color: '#ec4899', + tagline: 'Deep in the framework wars. You sweat the details.', + }, + { + id: 'ai-specialist', + name: 'AI Specialist', + emoji: '🤖', + color: '#22c55e', + tagline: 'You live in Claude, agents, and RAG pipelines. AI is your work.', + }, + { + id: 'backend-developer', + name: 'Backend Developer', + emoji: '🛠️', + color: '#3b82f6', + tagline: 'SQL, APIs, queues, databases. You make the data move.', + }, + { + id: 'software-architect', + name: 'Software Architect', + emoji: '🏛️', + color: '#14b8a6', + tagline: 'Microservices, distributed systems, scale. You draw the boxes.', + }, + { + id: 'systems-programmer', + name: 'Systems Programmer', + emoji: '⚡', + color: '#f97316', + tagline: 'Go, Rust, C++. Memory matters. Performance matters.', + }, + { + id: 'devops-engineer', + name: 'DevOps Engineer', + emoji: '🐳', + color: '#0ea5e9', + tagline: 'Kubernetes, CI/CD, observability. You keep prod alive.', + }, + { + id: 'php-developer', + name: 'PHP Developer', + emoji: '🐘', + color: '#777bb3', + tagline: "Laravel, Symfony, WordPress. The web's quiet workhorse.", + }, + { + id: 'security-engineer', + name: 'Security Engineer', + emoji: '🛡️', + color: '#dc2626', + tagline: 'CVEs, authentication, attack surface. You find the bugs first.', + }, + { + id: 'dotnet-developer', + name: '.NET Developer', + emoji: '🪟', + color: '#512bd4', + tagline: 'C#, ASP.NET, Blazor. The Microsoft stack done right.', + }, + { + id: 'game-developer', + name: 'Game Developer', + emoji: '🎮', + color: '#a855f7', + tagline: 'Unity, Unreal, Godot. You ship frames per second.', + }, + { + id: 'mobile-developer', + name: 'Mobile Developer', + emoji: '📱', + color: '#f43f5e', + tagline: 'iOS, Android, Flutter. The app store is your stage.', + }, + { + id: 'operator', + name: 'The Operator', + emoji: '💼', + color: '#64748b', + tagline: 'Product, design, strategy. You ship outcomes.', + }, +]; + +export const QUESTIONS: PersonaQuestion[] = [ + { + text: 'Do you ship the things users see and click on?', + layer: 0, + exclusiveGroup: 'primary-domain', + closesOnNo: ['primary-platform'], + }, + { + text: 'Is your work mostly backend or infrastructure, not frontend or mobile?', + layer: 0, + exclusiveGroup: 'primary-domain', + closesOnYes: ['primary-platform'], + }, + { + text: 'Is writing code not part of your day-to-day job?', + layer: 0, + lockPersonaId: 'operator', + }, + { + text: 'Is your main output a web app people open in a browser?', + layer: 1, + exclusiveGroup: 'primary-platform', + }, + { text: 'Are you faster in a terminal than in any GUI?', layer: 1 }, + { + text: 'Is your daily IDE Xcode or Android Studio?', + layer: 1, + lockPersonaId: 'mobile-developer', + exclusiveGroup: 'primary-platform', + }, + { + text: 'Does your day involve Jupyter notebooks, datasets, or training runs?', + layer: 1, + exclusiveGroup: 'primary-platform', + }, + { + text: 'Is your main language TypeScript or JavaScript?', + layer: 2, + exclusiveGroup: 'main-language', + }, + { + text: 'Is your main language Python?', + layer: 2, + exclusiveGroup: 'main-language', + }, + { + text: 'Is your main language Go, Rust, or C/C++?', + layer: 2, + exclusiveGroup: 'main-language', + }, + { + text: 'Is your main language Java or Kotlin?', + layer: 2, + exclusiveGroup: 'main-language', + }, + { + text: 'Is AI what you build, not just what you use?', + layer: 2, + lockPersonaId: 'ai-specialist', + }, + { + text: 'Is your main language PHP?', + layer: 2, + lockPersonaId: 'php-developer', + exclusiveGroup: 'main-language', + }, + { + text: 'Is your main stack C# / .NET?', + layer: 2, + lockPersonaId: 'dotnet-developer', + exclusiveGroup: 'main-language', + }, + { + text: 'Are Kubernetes, CI/CD, and observability what you ship?', + layer: 3, + lockPersonaId: 'devops-engineer', + }, + { text: 'Do you write more SQL than CSS?', layer: 3 }, + { + text: 'Have you drawn boxes and arrows on a whiteboard this month?', + layer: 3, + }, + { + text: 'Do you specialize in one stack rather than dabble across many?', + layer: 2, + }, + { + text: 'Do you build games or interactive 3D experiences?', + layer: 2, + lockPersonaId: 'game-developer', + }, + { + text: 'Is your week threat modeling, pen tests, and vulnerability triage?', + layer: 2, + lockPersonaId: 'security-engineer', + }, +]; + +export const MODIFIERS: PersonaModifier[] = [ + { + id: 'ai-heavy', + label: 'AI Heavy', + emoji: '🤖', + description: + 'You use AI tools (Cursor, Claude, agents) for meaningful chunks of your work.', + }, + { + id: 'founder', + label: 'Founder', + emoji: '🚀', + description: "You're building your own product, startup, or side business.", + }, + { + id: 'engineering-leader', + label: 'Engineering Leader', + emoji: '📰', + description: + 'You lead engineers or set technical direction more than you write code.', + }, +]; + +/** + * Likelihood matrix: P[persona][question] = probability a member of that + * persona answers yes. Computed from 90d engagement data on 93,345 active + * daily.dev users. 13 engineer-persona rows are data-grounded via K-means. + * The Operator row and the 'don't write code' column are hand-crafted + * (non-engineers do not appear in the engagement clustering). + */ +export const PERSONA_QUESTION_LIKELIHOOD: number[][] = [ + [ + 0.192, 0.263, 0, 0.148, 0.411, 0.005, 0.029, 0.358, 0.16, 0.331, 0.05, + 0.003, 0.002, 0.001, 0.186, 0.244, 0.206, 0.03, 0.001, 0, + ], + [ + 0.956, 0.002, 0, 0.955, 0.111, 0.011, 0.008, 0.97, 0.082, 0.189, 0.03, + 0.011, 0.005, 0.001, 0.071, 0.151, 0.103, 0.004, 0.002, 0, + ], + [ + 1, 0, 0, 1, 0.035, 0.019, 0.005, 1, 0.044, 0.065, 0.01, 0, 0.012, 0.002, + 0.011, 0.045, 0.026, 0.826, 0.006, 0, + ], + [ + 0.062, 0.052, 0, 0.052, 0.08, 0.006, 0.019, 0.088, 0.131, 0.055, 0.02, + 0.891, 0.007, 0.004, 0.041, 0.04, 0.066, 0.568, 0.002, 0, + ], + [ + 0.095, 0.863, 0, 0.099, 0.083, 0.005, 0.015, 0.118, 0.091, 0.078, 0.35, + 0.001, 0.006, 0.002, 0.036, 0.999, 0.136, 0.157, 0.002, 0, + ], + [ + 0.061, 0.214, 0, 0.066, 0.066, 0.006, 0.033, 0.114, 0.152, 0.095, 0.3, + 0.016, 0.005, 0.002, 0.048, 0.213, 0.44, 0.043, 0.001, 0, + ], + [ + 0.103, 0.85, 0, 0.097, 0.95, 0.006, 0.026, 0.167, 0.126, 0.987, 0.05, 0.002, + 0.005, 0.002, 0.119, 0.159, 0.153, 0.066, 0.002, 0, + ], + [ + 0.073, 0.9, 0, 0.07, 0.936, 0.007, 0.02, 0.154, 0.111, 0.172, 0.05, 0.004, + 0.004, 0.001, 0.939, 0.234, 0.196, 0.118, 0.001, 0, + ], + [ + 0.987, 0.13, 0, 0.999, 0.12, 0.009, 0.006, 0.343, 0.066, 0.153, 0.01, 0.001, + 0.876, 0.001, 0.096, 0.197, 0.077, 0.137, 0.003, 0, + ], + [ + 0.344, 0.479, 0, 0.412, 0.273, 0.014, 0.014, 0.39, 0.1, 0.096, 0.05, 0.027, + 0.028, 0.007, 0.055, 0.077, 0.048, 0.164, 0.009, 0.394, + ], + [ + 0.1, 0.836, 0, 0.099, 0.171, 0.006, 0.005, 0.217, 0.049, 0.163, 0.02, 0, + 0.003, 0.824, 0.073, 0.205, 0.222, 0.143, 0.008, 0, + ], + [ + 0.944, 0.001, 0, 0.161, 0.166, 0.022, 0.03, 0.247, 0.143, 0.207, 0.05, + 0.001, 0.006, 0.01, 0.034, 0.079, 0.047, 0.136, 0.771, 0, + ], + [ + 0.986, 0.002, 0, 0.266, 0.094, 0.989, 0.008, 0.296, 0.077, 0.046, 0.3, + 0.002, 0.007, 0.004, 0.026, 0.101, 0.085, 0.131, 0.01, 0, + ], + [ + 0.2, 0.03, 0.95, 0.05, 0.02, 0.03, 0.05, 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 to balance large and niche personas). */ +export const PERSONA_PRIOR: number[] = [ + 0.2163, 0.1645, 0.0746, 0.0754, 0.0732, 0.1093, 0.0543, 0.042, 0.0407, 0.0498, + 0.0241, 0.0181, 0.021, 0.0366, +]; + +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..019a083eb67 --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts @@ -0,0 +1,383 @@ +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 } from './data'; + +export type PersonaQuizPhase = + | 'intro' + | 'playing' + | 'tiebreak' + | 'triplebreak' + | 'modifiers' + | 'picker' + | 'reveal'; + +/** 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[]; +} + +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; + 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 [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 + ) { + revealGuess(top.index, nextBelief); + return; + } + + // 2. Belief is too diffuse: fall back to the generalist. + if (top.belief < fallbackFloor) { + revealGuess(FALLBACK_PERSONA_INDEX, nextBelief); + return; + } + + // 3. Belief is moderate but not decisive: two-way pick. + if (top.belief >= triplebreakFloor && runnerUp) { + 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) { + setTiebreak(candidates.slice(0, 3)); + setBelief(nextBelief); + setPhase(candidates.length >= 3 ? 'triplebreak' : 'tiebreak'); + return; + } + + 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); + 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) { + 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); + 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); + 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; + } + return { + persona: PERSONAS[resultIndex], + confidence: belief[resultIndex], + modifiers: 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, + 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/webapp/pages/onboarding-persona-demo.tsx b/packages/webapp/pages/onboarding-persona-demo.tsx new file mode 100644 index 00000000000..3194a06e954 --- /dev/null +++ b/packages/webapp/pages/onboarding-persona-demo.tsx @@ -0,0 +1,49 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { NextSeoProps } from 'next-seo'; +import { FunnelPersonaQuiz } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelPersonaQuiz'; +import { FunnelStepBackground } from '@dailydotdev/shared/src/features/onboarding/shared/FunnelStepBackground'; +import type { FunnelStepPersonaQuiz } from '@dailydotdev/shared/src/features/onboarding/types/funnel'; +import { + FunnelBackgroundVariant, + FunnelStepType, +} from '@dailydotdev/shared/src/features/onboarding/types/funnel'; +import { defaultOpenGraph, defaultSeo } from '../next-seo'; +import { getPageSeoTitles } from '../components/layouts/utils'; + +const seoTitles = getPageSeoTitles('Persona quiz demo'); +const seo: NextSeoProps = { + title: seoTitles.title, + openGraph: { ...seoTitles.openGraph, ...defaultOpenGraph }, + nofollow: true, + noindex: true, + ...defaultSeo, +}; + +function PersonaQuizDemo(): ReactElement { + const step = { + id: 'persona-quiz-demo', + type: FunnelStepType.PersonaQuiz, + isActive: true, + parameters: { + backgroundType: FunnelBackgroundVariant.Default, + mascotVideoBaseUrl: '/onboarding/patchy', + }, + transitions: [], + onTransition: () => undefined, + } as unknown as FunnelStepPersonaQuiz; + + return ( +
+ +
+ +
+
+
+ ); +} + +PersonaQuizDemo.layoutProps = { seo }; + +export default PersonaQuizDemo; 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