(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 (
+
+
+ {MASCOT_EMOJI}
+
+
+
+ {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;