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..79ff5c910bb --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.module.css @@ -0,0 +1,170 @@ +/* Micro-interactions for the Patchy persona quiz. Scoped via CSS modules. */ + +@keyframes patchy-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + +@keyframes patchy-bounce { + 0% { + transform: translateY(0) scale(1); + } + 35% { + transform: translateY(-14px) scale(1.16); + } + 60% { + transform: translateY(0) scale(0.96); + } + 100% { + transform: translateY(0) scale(1); + } +} + +@keyframes patchy-wiggle { + 0%, + 100% { + transform: rotate(0deg); + } + 20% { + transform: rotate(-11deg); + } + 40% { + transform: rotate(9deg); + } + 60% { + transform: rotate(-6deg); + } + 80% { + transform: rotate(4deg); + } +} + +@keyframes patchy-tilt { + 0%, + 100% { + transform: rotate(0deg); + } + 50% { + transform: rotate(-15deg) translateY(-4px); + } +} + +@keyframes question-in { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes dot-blink { + 0%, + 80%, + 100% { + opacity: 0.25; + transform: scale(0.7); + } + 40% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes reveal-spring { + 0% { + opacity: 0; + transform: scale(0.4); + } + 60% { + opacity: 1; + transform: scale(1.15); + } + 100% { + transform: scale(1); + } +} + +@keyframes reveal-glow { + 0%, + 100% { + filter: drop-shadow(0 0 28px var(--persona-glow)); + } + 50% { + filter: drop-shadow(0 0 64px var(--persona-glow)); + } +} + +@keyframes reveal-rise { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.float { + animation: patchy-float 3s ease-in-out infinite; +} + +.bounce { + animation: patchy-bounce 0.5s ease; +} + +.wiggle { + animation: patchy-wiggle 0.5s ease; +} + +.tilt { + animation: patchy-tilt 0.5s ease; +} + +.questionIn { + animation: question-in 0.35s ease both; +} + +.dot { + animation: dot-blink 1.2s ease-in-out infinite; +} + +.revealEmoji { + animation: reveal-spring 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both, + reveal-glow 2.4s ease-in-out 0.6s 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) { + .float, + .bounce, + .wiggle, + .tilt, + .questionIn, + .dot, + .revealEmoji, + .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..f87b3f6229e --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx @@ -0,0 +1,566 @@ +import type { CSSProperties, ReactElement } from 'react'; +import React, { useEffect, 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 { ColorName as ButtonColor } from '../../../styles/colors'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import ConfettiSvg from '../../../svg/ConfettiSvg'; +import { usePersonaQuiz } from './persona/usePersonaQuiz'; +import type { AnswerValue } from './persona/engine'; +import type { DeveloperPersona } from './persona/data'; +import styles from './FunnelPersonaQuiz.module.css'; + +// Placeholder until the Patchy mascot creative is ready. +const MASCOT_EMOJI = '🧞'; + +const MASCOT_GLOW = 'drop-shadow(0 0 40px rgba(192,132,252,.45))'; + +type MascotReaction = 'bounce' | 'wiggle' | 'tilt'; + +const reactionForAnswer = (value: AnswerValue): MascotReaction => { + if (value === 1) { + return 'bounce'; + } + if (value === 0) { + return 'wiggle'; + } + return 'tilt'; +}; + +const THINKING_DOT_DELAYS = [0, 0.16, 0.32]; + +const ThinkingDots = (): ReactElement => ( + + {THINKING_DOT_DELAYS.map((delay) => ( + + ))} + +); + +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 }, + onTransition, +}: FunnelStepPersonaQuiz): ReactElement { + const { + phase, + questionNumber, + questionText, + isThinking, + tiebreakPersonas, + triplebreakPersonas, + modifiers, + selectedModifierIds, + personas, + result, + isManual, + questionsAnswered, + start, + answer, + chooseTiebreak, + pickManually, + selectPersona, + confirmPersona, + toggleModifier, + } = usePersonaQuiz(); + + // Drives the mascot reaction; reactionKey forces the animation to replay on + // every tap, even when the same reaction repeats. + const [reaction, setReaction] = useState(null); + const [reactionKey, setReactionKey] = useState(0); + + useEffect(() => { + setReaction(null); + }, [questionText]); + + const handleAnswer = (value: AnswerValue) => { + if (isThinking) { + return; + } + setReaction(reactionForAnswer(value)); + setReactionKey((key) => key + 1); + 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 ( +
+ + {MASCOT_EMOJI} + +
+ + {headline || 'Let Patchy guess what kind of dev you are'} + + + {explainer || + 'A few quick yes/no questions. Patchy handles the rest.'} + +
+
+ + +
+
+ ); + } + + 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 ( +
+ + {MASCOT_EMOJI} + + + I'm torn between these two. + + + Which one feels more like you? + +
+ {tiebreakPersonas.map((persona) => ( + + ))} +
+
+ ); + } + + if (phase === 'triplebreak') { + return ( +
+ + {MASCOT_EMOJI} + + + 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 ( +
+ + {MASCOT_EMOJI} + + + 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 ( +
+
+ + + {persona.emoji} + +
+ + {persona.name} + + + {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..d38cf55968d --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/persona/data.ts @@ -0,0 +1,317 @@ +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: 'tech-strategist', + name: 'Tech Strategist', + emoji: '💼', + color: '#64748b', + tagline: + 'Product, design, strategy. You shape what gets built without writing the code.', + }, +]; + +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, not frontend or mobile.', + layer: 0, + exclusiveGroup: 'primary-domain', + closesOnYes: ['primary-platform'], + }, + { + text: "You don't write code as part of your day-to-day job.", + layer: 0, + lockPersonaId: 'tech-strategist', + }, + { + 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: 'You build apps for iPhone or Android.', + layer: 1, + lockPersonaId: 'mobile-developer', + exclusiveGroup: 'primary-platform', + }, + { + text: 'Your day involves Jupyter notebooks, datasets, or training runs.', + layer: 1, + exclusiveGroup: 'primary-platform', + }, + { + 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: 'AI is what you build, not just what you use.', + layer: 2, + lockPersonaId: 'ai-specialist', + }, + { + text: 'Your main language is PHP.', + layer: 2, + lockPersonaId: 'php-developer', + exclusiveGroup: 'main-language', + }, + { + text: 'Your main stack is C# / .NET.', + layer: 2, + lockPersonaId: 'dotnet-developer', + exclusiveGroup: 'main-language', + }, + { + text: 'DevOps, SRE, or platform engineering is your job, not just a side responsibility.', + 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 specialize in one stack. You don't dabble across many.", + layer: 2, + }, + { + text: 'You build games or interactive 3D experiences.', + layer: 2, + lockPersonaId: 'game-developer', + }, + { + text: 'Security is your primary job, not a side concern.', + 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: 'indie-hacker', + label: 'Indie Hacker', + 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. + * Tech Strategist 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..fa8d57f1f97 --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts @@ -0,0 +1,384 @@ +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 ANSWER_LABELS: Record = { + 0: 'no', + 0.5: 'not sure', + 1: 'yes', +}; + +const { + confidenceThreshold, + tiebreakThreshold, + tiebreakMargin, + triplebreakFloor, + fallbackFloor, + fallbackPersonaId, + maxQuestions, + minQuestions, + instantLockThreshold, + instantLockMargin, +} = PERSONA_ENGINE_CONFIG; + +const FALLBACK_PERSONA_INDEX = personaIndexById(fallbackPersonaId); + +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([]); + + const askedRef = useRef>(new Set()); + const excludedGroupsRef = useRef>(new Set()); + const answerLogRef = useRef< + Array<{ id: string; text: string; answer: string }> + >([]); + 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); + }, + [finish], + ); + + const start = useCallback(() => { + askedRef.current = new Set(); + excludedGroupsRef.current = new Set(); + answerLogRef.current = []; + const fresh = initialBelief(); + setBelief(fresh); + setResultIndex(null); + setTiebreak(null); + setIsThinking(false); + setIsManual(false); + setSelectedModifierIds([]); + 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); + + answerLogRef.current.push({ + id: `q${currentQuestion}`, + text: question.text, + answer: ANSWER_LABELS[value], + }); + // Debug aid: full answer history with question ids. + // eslint-disable-next-line no-console + console.log('[persona-quiz] answers', answerLogRef.current); + + 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) { + for (const group of question.closesOnYes) { + excludedGroupsRef.current.add(group); + } + } + if (value === 0 && question.closesOnNo) { + for (const group of question.closesOnNo) { + 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([]); + askedRef.current = new Set(); + excludedGroupsRef.current = new Set(); + answerLogRef.current = []; + }, []); + + 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: Math.min(questionsShown / maxQuestions, 1), + 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..83a64dbbc20 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,22 @@ export interface FunnelStepUploadCv onTransition: FunnelStepTransitionCallback; } +export interface FunnelStepPersonaQuiz + extends FunnelStepCommon<{ + headline?: string; + explainer?: string; + cta?: string; + }> { + type: FunnelStepType.PersonaQuiz; + onTransition: FunnelStepTransitionCallback<{ + persona?: string; + confidence?: number; + questions: number; + manual: boolean; + modifiers: string[]; + }>; +} + export type FunnelStep = | FunnelStepLandingPage | FunnelStepFact @@ -397,7 +414,8 @@ export type FunnelStep = | FunnelStepOrganicCheckout | FunnelStepBrowserExtension | FunnelStepPlusCards - | FunnelStepUploadCv; + | FunnelStepUploadCv + | FunnelStepPersonaQuiz; export type FunnelPosition = { chapter: number; @@ -446,4 +464,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..d7ba4604bdf --- /dev/null +++ b/packages/webapp/pages/onboarding-persona-demo.tsx @@ -0,0 +1,114 @@ +import type { ReactElement } from 'react'; +import React, { useState } 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, + FunnelStepTransitionCallback, +} from '@dailydotdev/shared/src/features/onboarding/types/funnel'; +import { + FunnelBackgroundVariant, + FunnelStepType, +} from '@dailydotdev/shared/src/features/onboarding/types/funnel'; +import { Button } from '@dailydotdev/shared/src/components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +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, +}; + +type CompletionDetails = { + persona?: string; + modifiers?: string[]; + confidence?: number; + questions: number; +}; + +function PersonaQuizDemo(): ReactElement { + const [completion, setCompletion] = useState(null); + const [runKey, setRunKey] = useState(0); + + const onTransition: FunnelStepTransitionCallback = ({ + details, + }) => { + setCompletion(details ?? null); + }; + + const step = { + id: 'persona-quiz-demo', + type: FunnelStepType.PersonaQuiz, + isActive: true, + parameters: { + backgroundType: FunnelBackgroundVariant.Default, + }, + transitions: [], + onTransition, + } as unknown as FunnelStepPersonaQuiz; + + return ( +
+ +
+ +
+
+ + {completion && ( +
+ + onTransition(complete) + + + persona: {completion.persona ?? 'n/a'} +
+ confidence:{' '} + + {completion.confidence != null + ? `${Math.round(completion.confidence * 100)}%` + : 'n/a'} + +
+ modifiers:{' '} + + {completion.modifiers && completion.modifiers.length > 0 + ? completion.modifiers.join(', ') + : 'none'} + +
+ questions: {completion.questions} +
+ +
+ )} +
+ ); +} + +PersonaQuizDemo.layoutProps = { seo }; + +export default PersonaQuizDemo;