diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx index 39535963511..660895b5420 100644 --- a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx +++ b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx @@ -35,7 +35,6 @@ import { FunnelHeroLanding, FunnelBrowserExtension, FunnelUploadCv, - FunnelPersonaQuiz, } from '../steps'; import { FunnelFact } from '../steps/FunnelFact'; import { FunnelCheckout } from '../steps/FunnelCheckout'; @@ -82,7 +81,6 @@ 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 deleted file mode 100644 index bf8b021f478..00000000000 --- a/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.module.css +++ /dev/null @@ -1,557 +0,0 @@ -/* 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 deleted file mode 100644 index 7018b733dc6..00000000000 --- a/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx +++ /dev/null @@ -1,1188 +0,0 @@ -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'; - -const DEFAULT_MASCOT_VIDEO_BASE_URL = '/onboarding/patchy'; - -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', -}; - -// 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(); - } - }; - - 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: configuredMascotVideoBaseUrl, - }, - onTransition, -}: FunnelStepPersonaQuiz): ReactElement { - const mascotVideoBaseUrl = - configuredMascotVideoBaseUrl || DEFAULT_MASCOT_VIDEO_BASE_URL; - 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); - // 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 4f411452c63..5352bf46933 100644 --- a/packages/shared/src/features/onboarding/steps/index.ts +++ b/packages/shared/src/features/onboarding/steps/index.ts @@ -13,4 +13,3 @@ 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 deleted file mode 100644 index 90d6996c545..00000000000 --- a/packages/shared/src/features/onboarding/steps/persona/data.ts +++ /dev/null @@ -1,458 +0,0 @@ -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 deleted file mode 100644 index efeea2db938..00000000000 --- a/packages/shared/src/features/onboarding/steps/persona/engine.ts +++ /dev/null @@ -1,179 +0,0 @@ -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 deleted file mode 100644 index bb3a441f2af..00000000000 --- a/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts +++ /dev/null @@ -1,415 +0,0 @@ -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 bb73091191e..b7a64c065b9 100644 --- a/packages/shared/src/features/onboarding/types/funnel.ts +++ b/packages/shared/src/features/onboarding/types/funnel.ts @@ -36,7 +36,6 @@ export enum FunnelStepType { HeroLanding = 'heroLanding', BrowserExtension = 'browserExtension', UploadCv = 'uploadCv', - PersonaQuiz = 'personaQuiz', } export enum FunnelBackgroundVariant { @@ -416,28 +415,6 @@ 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 @@ -459,8 +436,7 @@ export type FunnelStep = | FunnelStepHeroLanding | FunnelStepBrowserExtension | FunnelStepPlusCards - | FunnelStepUploadCv - | FunnelStepPersonaQuiz; + | FunnelStepUploadCv; export type FunnelPosition = { chapter: number; @@ -510,5 +486,4 @@ 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 ea7c4677a9e..395c10590f1 100644 --- a/packages/shared/src/features/onboarding/types/funnelEvents.ts +++ b/packages/shared/src/features/onboarding/types/funnelEvents.ts @@ -27,9 +27,6 @@ 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/image.ts b/packages/shared/src/lib/image.ts index e955e3b24c7..551ed3f8f1e 100644 --- a/packages/shared/src/lib/image.ts +++ b/packages/shared/src/lib/image.ts @@ -488,8 +488,8 @@ export const cloudinaryGivebackPunchyStaring = // Bespoke Patchy assets for the Giveback "Picture with Patchy" reward reveal. // The selfie is a transparent video played once — a VP9-alpha .webm for most -// browsers and an HEVC-alpha .mov for Safari (the same source-order trick as -// PersonaMascot); both carry real alpha, so Patchy floats cleanly on any +// browsers and an HEVC-alpha .mov for Safari (source-ordered so Safari falls +// back to the .mov); both carry real alpha, so Patchy floats cleanly on any // surface. The card frame is the still Patchy holds with the visitor's photo // dropped in. export const cloudinaryGivebackPatchySelfieWebm = diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 93c6b2c9e80..4d37a4b92b2 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -492,7 +492,6 @@ 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 deleted file mode 100644 index f25ed083be7..00000000000 Binary files a/packages/webapp/public/onboarding/patchy-idle1.mov and /dev/null differ diff --git a/packages/webapp/public/onboarding/patchy-idle1.webm b/packages/webapp/public/onboarding/patchy-idle1.webm deleted file mode 100644 index a28860c59c8..00000000000 Binary files a/packages/webapp/public/onboarding/patchy-idle1.webm and /dev/null differ diff --git a/packages/webapp/public/onboarding/patchy-idle2.mov b/packages/webapp/public/onboarding/patchy-idle2.mov deleted file mode 100644 index 6d5c0c64cf2..00000000000 Binary files a/packages/webapp/public/onboarding/patchy-idle2.mov and /dev/null differ diff --git a/packages/webapp/public/onboarding/patchy-idle2.webm b/packages/webapp/public/onboarding/patchy-idle2.webm deleted file mode 100644 index 3365a938ac9..00000000000 Binary files a/packages/webapp/public/onboarding/patchy-idle2.webm and /dev/null differ diff --git a/packages/webapp/public/onboarding/patchy-onpath.mov b/packages/webapp/public/onboarding/patchy-onpath.mov deleted file mode 100644 index 99f9f069497..00000000000 Binary files a/packages/webapp/public/onboarding/patchy-onpath.mov and /dev/null differ diff --git a/packages/webapp/public/onboarding/patchy-onpath.webm b/packages/webapp/public/onboarding/patchy-onpath.webm deleted file mode 100644 index 958370a0c1f..00000000000 Binary files a/packages/webapp/public/onboarding/patchy-onpath.webm and /dev/null differ diff --git a/packages/webapp/public/onboarding/patchy-reveal.mov b/packages/webapp/public/onboarding/patchy-reveal.mov deleted file mode 100644 index 68e2c0afbf2..00000000000 Binary files a/packages/webapp/public/onboarding/patchy-reveal.mov and /dev/null differ diff --git a/packages/webapp/public/onboarding/patchy-reveal.webm b/packages/webapp/public/onboarding/patchy-reveal.webm deleted file mode 100644 index bf11c6eb2b0..00000000000 Binary files a/packages/webapp/public/onboarding/patchy-reveal.webm and /dev/null differ diff --git a/packages/webapp/public/onboarding/patchy-thinking.mov b/packages/webapp/public/onboarding/patchy-thinking.mov deleted file mode 100644 index 826b765f139..00000000000 Binary files a/packages/webapp/public/onboarding/patchy-thinking.mov and /dev/null differ diff --git a/packages/webapp/public/onboarding/patchy-thinking.webm b/packages/webapp/public/onboarding/patchy-thinking.webm deleted file mode 100644 index ceed78656cb..00000000000 Binary files a/packages/webapp/public/onboarding/patchy-thinking.webm and /dev/null differ diff --git a/packages/webapp/public/onboarding/patchy-unsure.mov b/packages/webapp/public/onboarding/patchy-unsure.mov deleted file mode 100644 index ef93bf43a6b..00000000000 Binary files a/packages/webapp/public/onboarding/patchy-unsure.mov and /dev/null differ diff --git a/packages/webapp/public/onboarding/patchy-unsure.webm b/packages/webapp/public/onboarding/patchy-unsure.webm deleted file mode 100644 index 01d54cb0bee..00000000000 Binary files a/packages/webapp/public/onboarding/patchy-unsure.webm and /dev/null differ