From 1b3f3540621cddad20af3d7540bc097d502a3870 Mon Sep 17 00:00:00 2001
From: Ido Shamun <1993245+idoshamun@users.noreply.github.com>
Date: Wed, 3 Jun 2026 21:23:57 +0300
Subject: [PATCH 01/15] feat(onboarding): add Patchy persona quiz step + demo
page
---
.../onboarding/shared/FunnelStepper.tsx | 2 +
.../onboarding/steps/FunnelPersonaQuiz.tsx | 294 ++++++++++++++++++
.../src/features/onboarding/steps/index.ts | 1 +
.../features/onboarding/steps/persona/data.ts | 249 +++++++++++++++
.../onboarding/steps/persona/engine.ts | 137 ++++++++
.../steps/persona/usePersonaQuiz.ts | 240 ++++++++++++++
.../src/features/onboarding/types/funnel.ts | 20 +-
.../webapp/pages/onboarding-persona-demo.tsx | 106 +++++++
8 files changed, 1048 insertions(+), 1 deletion(-)
create mode 100644 packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx
create mode 100644 packages/shared/src/features/onboarding/steps/persona/data.ts
create mode 100644 packages/shared/src/features/onboarding/steps/persona/engine.ts
create mode 100644 packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
create mode 100644 packages/webapp/pages/onboarding-persona-demo.tsx
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.tsx b/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx
new file mode 100644
index 00000000000..336005a8a80
--- /dev/null
+++ b/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx
@@ -0,0 +1,294 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+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 { usePersonaQuiz } from './persona/usePersonaQuiz';
+
+// Placeholder until the Patchy mascot creative is ready.
+const MASCOT_EMOJI = '๐ง';
+
+function FunnelPersonaQuizComponent({
+ parameters: { headline, explainer, cta },
+ onTransition,
+}: FunnelStepPersonaQuiz): ReactElement {
+ const {
+ phase,
+ questionText,
+ isThinking,
+ tiebreakPersonas,
+ personas,
+ result,
+ isManual,
+ questionsAnswered,
+ start,
+ answer,
+ chooseTiebreak,
+ pickManually,
+ selectPersona,
+ } = usePersonaQuiz();
+
+ const handleComplete = () => {
+ onTransition({
+ type: FunnelStepTransitionType.Complete,
+ details: {
+ persona: result?.persona.id,
+ 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.'}
+
+
+
+
+ {cta || 'Game on!'}
+
+
+ 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) => (
+ selectPersona(persona.id)}
+ className="flex w-full items-center gap-4 rounded-16 border border-border-subtlest-tertiary bg-surface-float p-4 text-left transition-colors hover:border-accent-cabbage-default"
+ >
+
+ {persona.emoji}
+
+
+
+ {persona.name}
+
+
+ {persona.tagline}
+
+
+
+ ))}
+
+
+ );
+ }
+
+ if (phase === 'tiebreak') {
+ return (
+
+
{MASCOT_EMOJI}
+
+ I'm torn between these two.
+
+
+ Which one feels more like you?
+
+
+ {tiebreakPersonas.map((persona) => (
+ chooseTiebreak(persona.id)}
+ className="flex flex-col items-center gap-2 rounded-16 border-2 border-border-subtlest-tertiary bg-surface-float p-8 text-center transition-all hover:-translate-y-1 hover:border-accent-cabbage-default"
+ >
+
+ {persona.emoji}
+
+
+ {persona.name}
+
+
+ {persona.tagline}
+
+
+ ))}
+
+
+ );
+ }
+
+ if (phase === 'reveal' && result) {
+ const { persona } = result;
+ return (
+
+
+ {persona.emoji}
+
+
+ {persona.name}
+
+
+ {persona.tagline}
+
+
+
+ {cta || "Yes, that's me!"}
+
+
+ Nah, I'll pick myself
+
+
+
+ );
+ }
+
+ return (
+
+
+ {MASCOT_EMOJI}
+
+
+
+ {questionText}
+
+
+ answer(1)}
+ >
+ Yes
+
+ answer(0.5)}
+ >
+ Not sure
+
+ answer(0)}
+ >
+ No
+
+
+
+
+ );
+}
+
+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..58ad838ac80
--- /dev/null
+++ b/packages/shared/src/features/onboarding/steps/persona/data.ts
@@ -0,0 +1,249 @@
+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;
+ /**
+ * Funnel depth the question belongs to. Lower layers are broad
+ * (frontend vs backend), higher layers are specific (which language,
+ * AI usage, on-call experience). The engine unlocks deeper layers as
+ * the belief narrows down.
+ */
+ layer: number;
+}
+
+export interface PersonaEngineConfig {
+ /** Confidence needed to stop early once minQuestions is reached. */
+ confidenceThreshold: number;
+ /** Top belief required to reveal directly instead of offering a tiebreak. */
+ tiebreakThreshold: number;
+ /** Minimum belief gap between the top two to reveal directly. */
+ tiebreakMargin: number;
+ /** Below this top belief, fall back to the generalist persona. */
+ fallbackFloor: number;
+ /** Persona id used when belief is too diffuse to call confidently. */
+ fallbackPersonaId: string;
+ maxQuestions: number;
+ minQuestions: number;
+}
+
+export const PERSONAS: DeveloperPersona[] = [
+ {
+ id: 'polyglot',
+ name: 'The Polyglot',
+ emoji: '๐ฆ',
+ color: '#f59e0b',
+ tagline:
+ "Your curiosity is too broad to pin down. We'll give you a wide-angle feed.",
+ },
+ {
+ id: 'web-craftsman',
+ name: 'The Web Craftsman',
+ emoji: 'โ๏ธ',
+ color: '#06b6d4',
+ tagline: 'You ship product. React, TypeScript, the works.',
+ },
+ {
+ id: 'frontend-purist',
+ name: 'The Frontend Purist',
+ emoji: '๐จ',
+ color: '#ec4899',
+ tagline: "Deep in frontend. You know every framework's quirks.",
+ },
+ {
+ id: 'tooling-nerd',
+ name: 'The Tooling Nerd',
+ emoji: '๐ง',
+ color: '#a78bfa',
+ tagline: 'VSCode, vim, git, Linux. Your dev environment is sacred.',
+ },
+ {
+ id: 'model-whisperer',
+ name: 'The Model Whisperer',
+ emoji: '๐ค',
+ color: '#22c55e',
+ tagline: 'Python, models, evals. You build the AI layer itself.',
+ },
+ {
+ id: 'ai-tinkerer',
+ name: 'The AI Tinkerer',
+ emoji: '๐ช',
+ color: '#8b5cf6',
+ tagline: 'You wire LLMs into web apps. Vibe-coding in Cursor.',
+ },
+ {
+ id: 'systems-hacker',
+ name: 'The Systems Hacker',
+ emoji: 'โก',
+ color: '#f97316',
+ tagline: 'Go, Rust, JVM. You write the safe-but-fast version.',
+ },
+ {
+ id: 'backend-veteran',
+ name: 'The Backend Veteran',
+ emoji: '๐ ๏ธ',
+ color: '#3b82f6',
+ tagline: 'SQL, APIs, databases. You make data move.',
+ },
+ {
+ id: 'industry-sage',
+ name: 'The Industry Sage',
+ emoji: '๐ฐ',
+ color: '#eab308',
+ tagline: "You spot trends before they're trends. You lead the team.",
+ },
+ {
+ id: 'architect',
+ name: 'The Architect',
+ emoji: '๐๏ธ',
+ color: '#14b8a6',
+ tagline: 'Microservices, distributed systems, scale. You draw the boxes.',
+ },
+ {
+ id: 'platform-whisperer',
+ name: 'The Platform Whisperer',
+ emoji: '๐ณ',
+ color: '#0ea5e9',
+ tagline: 'Kubernetes, CI/CD, observability. Prod is yours.',
+ },
+ {
+ id: 'app-builder',
+ name: 'The App Builder',
+ emoji: '๐ฑ',
+ color: '#f43f5e',
+ tagline: 'Native iOS or Android. The store is your stage.',
+ },
+];
+
+export const QUESTIONS: PersonaQuestion[] = [
+ { text: 'You ship the things users see and click on.', layer: 0 },
+ { text: 'Your code runs on servers, not in a browser.', layer: 0 },
+ {
+ text: 'You read more about the industry than you write code these days.',
+ layer: 0,
+ },
+ { text: 'Your main output is a web app people open in a browser.', layer: 1 },
+ { text: "You're faster in a terminal than in any GUI.", layer: 1 },
+ { text: 'You build apps for iPhone or Android.', layer: 1 },
+ {
+ text: 'Your day involves Jupyter notebooks, datasets, or training runs.',
+ layer: 1,
+ },
+ { text: 'Your main language is TypeScript or JavaScript.', layer: 2 },
+ { text: 'Your main language is Python.', layer: 2 },
+ { text: 'Your main language is Go, Rust, or C/C++.', layer: 2 },
+ {
+ text: 'AI tools are critical to your daily work, not just autocomplete.',
+ layer: 2,
+ },
+ {
+ text: "You've shipped code that calls OpenAI, Anthropic, or another LLM API.",
+ layer: 2,
+ },
+ {
+ text: "You've spent a weekend customizing your editor or dotfiles.",
+ layer: 2,
+ },
+ {
+ text: "You've been the one paged at 3am when production went down.",
+ layer: 3,
+ },
+ { text: "You've fine-tuned an ML model in the last six months.", layer: 2 },
+ { 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 know who Stripe, OpenAI, or Anthropic hired last week.',
+ layer: 3,
+ },
+ {
+ text: "You specialize in one stack. You don't dabble across many.",
+ layer: 2,
+ },
+ {
+ text: "You'd rather read a 30-page postmortem than a quick tutorial.",
+ layer: 3,
+ },
+ { text: 'You ship web apps with AI features built in.', layer: 2 },
+];
+
+/**
+ * Likelihood matrix: P[persona][question] = probability a member of that
+ * persona answers "yes". Trained offline; rows align with PERSONAS, columns
+ * with QUESTIONS.
+ */
+export const PERSONA_QUESTION_LIKELIHOOD: number[][] = [
+ [
+ 0.223, 0.442, 0.0, 0.216, 0.492, 0.007, 0.048, 0.53, 0.193, 0.468, 0.511,
+ 0.331, 0.316, 0.261, 0.056, 0.348, 0.293, 0.371, 0.01, 0.0, 0.043,
+ ],
+ [
+ 0.999, 0.189, 0.0, 1.0, 0.221, 0.02, 0.003, 1.0, 0.109, 0.285, 0.231, 0.193,
+ 0.193, 0.111, 0.0, 0.243, 0.155, 0.216, 0.008, 0.0, 0.038,
+ ],
+ [
+ 1.0, 0.045, 0.0, 1.0, 0.032, 0.024, 0.007, 1.0, 0.063, 0.083, 0.097, 0.065,
+ 0.05, 0.021, 0.014, 0.068, 0.04, 0.058, 0.9, 0.0, 0.076,
+ ],
+ [
+ 0.131, 0.363, 0.018, 0.127, 0.995, 0.012, 0.053, 0.189, 0.207, 0.296, 0.118,
+ 0.03, 1.0, 0.066, 0.035, 0.079, 0.052, 0.121, 0.188, 0.037, 0.008,
+ ],
+ [
+ 0.043, 0.145, 0.051, 0.035, 0.16, 0.012, 0.967, 0.112, 0.95, 0.118, 1.0,
+ 0.185, 0.165, 0.064, 1.0, 0.104, 0.12, 0.289, 0.204, 0.085, 0.035,
+ ],
+ [
+ 0.919, 0.079, 0.0, 0.926, 0.04, 0.011, 0.382, 0.951, 0.411, 0.116, 0.746,
+ 0.367, 0.05, 0.047, 0.521, 0.089, 0.092, 0.176, 0.017, 0.0, 0.878,
+ ],
+ [
+ 0.113, 0.991, 0.0, 0.108, 0.976, 0.015, 0.028, 0.207, 0.137, 0.999, 0.144,
+ 0.055, 0.195, 0.126, 0.036, 0.198, 0.243, 0.115, 0.094, 0.014, 0.006,
+ ],
+ [
+ 0.117, 0.152, 0.0, 0.117, 0.12, 0.014, 0.03, 0.16, 0.12, 0.114, 0.081,
+ 0.057, 0.087, 0.045, 0.037, 1.0, 0.15, 0.507, 0.163, 0.178, 0.005,
+ ],
+ [
+ 0.115, 0.156, 0.379, 0.122, 0.164, 0.008, 0.106, 0.198, 0.248, 0.118, 0.293,
+ 0.071, 0.157, 0.047, 0.133, 0.147, 0.149, 0.999, 0.119, 0.356, 0.029,
+ ],
+ [
+ 0.079, 0.216, 0.1, 0.072, 0.108, 0.019, 0.03, 0.143, 0.119, 0.147, 0.149,
+ 0.064, 0.052, 0.079, 0.047, 0.326, 0.963, 0.564, 0.067, 0.0, 0.004,
+ ],
+ [
+ 0.102, 0.996, 0.0, 0.106, 0.959, 0.009, 0.046, 0.197, 0.163, 0.191, 0.159,
+ 0.048, 0.207, 0.959, 0.051, 0.213, 0.181, 0.119, 0.131, 0.016, 0.008,
+ ],
+ [
+ 0.999, 0.15, 0.0, 0.335, 0.181, 1.0, 0.028, 0.385, 0.12, 0.148, 0.098,
+ 0.036, 0.137, 0.031, 0.038, 0.082, 0.098, 0.076, 0.213, 0.009, 0.026,
+ ],
+];
+
+/** Prior probability of each persona, aligned with PERSONAS. */
+export const PERSONA_PRIOR: number[] = [
+ 0.1876, 0.1673, 0.102, 0.0812, 0.0708, 0.0702, 0.0669, 0.0737, 0.0664, 0.0596,
+ 0.0363, 0.0179,
+];
+
+export const PERSONA_ENGINE_CONFIG: PersonaEngineConfig = {
+ confidenceThreshold: 0.75,
+ tiebreakThreshold: 0.5,
+ tiebreakMargin: 0.07,
+ fallbackFloor: 0.15,
+ fallbackPersonaId: 'polyglot',
+ maxQuestions: 12,
+ minQuestions: 6,
+};
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..8dedae428d4
--- /dev/null
+++ b/packages/shared/src/features/onboarding/steps/persona/engine.ts
@@ -0,0 +1,137 @@
+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. */
+export const pickNextQuestion = (
+ belief: number[],
+ asked: Set,
+ questionsShown: number,
+): number => {
+ const layers = allowedLayers(questionsShown);
+
+ return QUESTIONS.reduce(
+ (best, question, q) => {
+ if (asked.has(q) || !layers.has(question.layer)) {
+ return best;
+ }
+ const gain = informationGain(belief, q);
+ return gain > best.gain ? { index: q, gain } : best;
+ },
+ { index: -1, gain: -1 },
+ ).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..05188a936b5
--- /dev/null
+++ b/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
@@ -0,0 +1,240 @@
+import { useCallback, useMemo, useRef, useState } from 'react';
+import type { AnswerValue } from './engine';
+import {
+ initialBelief,
+ personaIndexById,
+ pickNextQuestion,
+ rankBelief,
+ updateBelief,
+} from './engine';
+import type { DeveloperPersona } from './data';
+import { PERSONAS, PERSONA_ENGINE_CONFIG, QUESTIONS } from './data';
+
+export type PersonaQuizPhase =
+ | 'intro'
+ | 'playing'
+ | 'tiebreak'
+ | 'picker'
+ | 'reveal';
+
+/** UI-only pause so the belief shift feels deliberate; not a game tunable. */
+const THINKING_DURATION_MS = 450;
+
+const {
+ confidenceThreshold,
+ tiebreakThreshold,
+ tiebreakMargin,
+ fallbackFloor,
+ fallbackPersonaId,
+ maxQuestions,
+ minQuestions,
+} = PERSONA_ENGINE_CONFIG;
+
+const FALLBACK_PERSONA_INDEX = personaIndexById(fallbackPersonaId);
+
+interface PersonaResult {
+ persona: DeveloperPersona;
+ confidence: number;
+}
+
+export interface PersonaQuizState {
+ phase: PersonaQuizPhase;
+ belief: number[];
+ questionNumber: number;
+ questionText: string | null;
+ progress: number;
+ isThinking: boolean;
+ tiebreakPersonas: DeveloperPersona[];
+ 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;
+ 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<[number, number] | null>(null);
+ const [resultIndex, setResultIndex] = useState(null);
+ const [isManual, setIsManual] = useState(false);
+
+ const askedRef = useRef>(new Set());
+ const thinkingTimeout = useRef>();
+
+ const reveal = useCallback((index: number, nextBelief: number[]) => {
+ setResultIndex(index);
+ setTiebreak(null);
+ setPhase('reveal');
+ setBelief(nextBelief);
+ }, []);
+
+ const finish = useCallback(
+ (nextBelief: number[]) => {
+ const [top, runnerUp] = rankBelief(nextBelief);
+
+ if (
+ top.belief >= tiebreakThreshold &&
+ top.belief - runnerUp.belief >= tiebreakMargin
+ ) {
+ reveal(top.index, nextBelief);
+ return;
+ }
+
+ if (top.belief < fallbackFloor) {
+ reveal(FALLBACK_PERSONA_INDEX, nextBelief);
+ return;
+ }
+
+ setTiebreak([top.index, runnerUp.index]);
+ setBelief(nextBelief);
+ setPhase('tiebreak');
+ },
+ [reveal],
+ );
+
+ const advance = useCallback(
+ (nextBelief: number[], shownSoFar: number) => {
+ const topBelief = Math.max(...nextBelief);
+ const reachedConfidence =
+ topBelief >= confidenceThreshold && shownSoFar >= minQuestions;
+
+ if (shownSoFar >= maxQuestions || reachedConfidence) {
+ finish(nextBelief);
+ return;
+ }
+
+ const next = pickNextQuestion(nextBelief, askedRef.current, shownSoFar);
+ if (next < 0) {
+ finish(nextBelief);
+ return;
+ }
+
+ askedRef.current.add(next);
+ setCurrentQuestion(next);
+ setQuestionsShown(shownSoFar + 1);
+ },
+ [finish],
+ );
+
+ const start = useCallback(() => {
+ askedRef.current = new Set();
+ const fresh = initialBelief();
+ setBelief(fresh);
+ setResultIndex(null);
+ setTiebreak(null);
+ setIsThinking(false);
+ setIsManual(false);
+ setQuestionsShown(0);
+ setPhase('playing');
+ advance(fresh, 0);
+ }, [advance]);
+
+ const answer = useCallback(
+ (value: AnswerValue) => {
+ if (currentQuestion === null || isThinking) {
+ return;
+ }
+
+ const nextBelief = updateBelief(belief, currentQuestion, value);
+ setBelief(nextBelief);
+ setIsThinking(true);
+
+ thinkingTimeout.current = setTimeout(() => {
+ setIsThinking(false);
+ advance(nextBelief, questionsShown);
+ }, THINKING_DURATION_MS);
+ },
+ [advance, belief, currentQuestion, isThinking, questionsShown],
+ );
+
+ const chooseTiebreak = useCallback(
+ (personaId: string) => {
+ const index = PERSONAS.findIndex((persona) => persona.id === personaId);
+ if (index < 0) {
+ return;
+ }
+ reveal(index, belief);
+ },
+ [belief, reveal],
+ );
+
+ const pickManually = useCallback(() => {
+ setResultIndex(null);
+ setTiebreak(null);
+ setIsManual(false);
+ setPhase('picker');
+ }, []);
+
+ const selectPersona = useCallback(
+ (personaId: string) => {
+ const index = PERSONAS.findIndex((persona) => persona.id === personaId);
+ if (index < 0) {
+ return;
+ }
+ setIsManual(true);
+ reveal(index, belief);
+ },
+ [belief, reveal],
+ );
+
+ 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);
+ askedRef.current = new Set();
+ }, []);
+
+ const tiebreakPersonas = useMemo(
+ () => (tiebreak ? tiebreak.map((index) => PERSONAS[index]) : []),
+ [tiebreak],
+ );
+
+ const result = useMemo(() => {
+ if (resultIndex === null) {
+ return null;
+ }
+ return {
+ persona: PERSONAS[resultIndex],
+ confidence: belief[resultIndex],
+ };
+ }, [belief, resultIndex]);
+
+ return {
+ phase,
+ belief,
+ questionNumber: questionsShown,
+ questionText:
+ currentQuestion !== null ? QUESTIONS[currentQuestion].text : null,
+ progress: Math.min(questionsShown / maxQuestions, 1),
+ isThinking,
+ tiebreakPersonas,
+ personas: PERSONAS,
+ result,
+ isManual,
+ questionsAnswered: askedRef.current.size,
+ start,
+ answer,
+ chooseTiebreak,
+ pickManually,
+ selectPersona,
+ restart,
+ };
+};
diff --git a/packages/shared/src/features/onboarding/types/funnel.ts b/packages/shared/src/features/onboarding/types/funnel.ts
index c22766ec560..b61a1e295d5 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,21 @@ 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;
+ }>;
+}
+
export type FunnelStep =
| FunnelStepLandingPage
| FunnelStepFact
@@ -397,7 +413,8 @@ export type FunnelStep =
| FunnelStepOrganicCheckout
| FunnelStepBrowserExtension
| FunnelStepPlusCards
- | FunnelStepUploadCv;
+ | FunnelStepUploadCv
+ | FunnelStepPersonaQuiz;
export type FunnelPosition = {
chapter: number;
@@ -446,4 +463,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..4ca382176fe
--- /dev/null
+++ b/packages/webapp/pages/onboarding-persona-demo.tsx
@@ -0,0 +1,106 @@
+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;
+ 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'}
+
+
+ questions: {completion.questions}
+
+ {
+ setCompletion(null);
+ setRunKey((key) => key + 1);
+ }}
+ >
+ Replay
+
+
+ )}
+
+ );
+}
+
+PersonaQuizDemo.layoutProps = { seo };
+
+export default PersonaQuizDemo;
From e47a4037704494aa684dc58af92493a7bee1fe6b Mon Sep 17 00:00:00 2001
From: idoshamun <16071328+idoshamun@users.noreply.github.com>
Date: Wed, 3 Jun 2026 18:32:23 +0000
Subject: [PATCH 02/15] feat(onboarding): refresh persona quiz with v5 data +
triple-tie UX
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bake the new 15-persona, 25-question quiz model into the Patchy quiz.
Data (data.ts):
- 15 personas, role-named (no "The" prefix), spanning mainstream stacks
and the data-grounded niche personas the original 12 missed:
Generalist ยท Full-Stack Web ยท Frontend Specialist ยท AI App Builder ยท
AI Specialist ยท Engineering Leader ยท Backend ยท Architect ยท Systems
Programmer ยท DevOps ยท PHP ยท Security ยท .NET ยท Game ยท Mobile.
- 25 questions, including five new locks (Q15 AI is your work,
Q17 PHP, Q18 .NET, Q24 games, Q25 security) and Q11/Q12 retuned
for the modern AI dev tooling era.
- Likelihood matrix derived from 93,539 active daily.dev users over
90 days. Mobile cluster hand-crafted from mobile-topic-heavy users.
- Prior switched to log-shaped distribution so niche personas can
compete with the much larger Generalist cluster without sacrificing
the headline 75% top-1 accuracy.
- New triplebreakFloor threshold (0.3) and renamed generalist
fallback to match the new persona id.
Engine / hook (usePersonaQuiz.ts):
- New 'triplebreak' phase for the I'm-torn-between-three case
(Security and Architect win it most often; both have weak top-2
signal because their content overlaps with Backend/Infra).
- finish() chooses reveal / triplebreak / tiebreak / fallback by
walking the same thresholds in decreasing confidence order.
- Tiebreak buffer now stores up to three indices and the memoised
selectors slice them down based on the current phase.
UI (FunnelPersonaQuiz.tsx):
- Render the new triplebreak phase with a three-column grid of
persona cards and an "None of these. Let me pick." escape hatch
that drops back into the manual picker.
- Extracted a small PersonaCard component so the tiebreak and
triplebreak screens stay visually consistent without duplicating
markup.
Headline metrics from the offline simulator on the v5 model:
Top-1 75% ยท Top-2 88% ยท Top-3 92% ยท feed quality 96% ยท all 15
personas reachable via yes/not-sure/no answer paths ยท median
6 questions to lock.
---
.../onboarding/steps/FunnelPersonaQuiz.tsx | 106 +++++--
.../features/onboarding/steps/persona/data.ts | 269 ++++++++----------
.../steps/persona/usePersonaQuiz.ts | 54 +++-
3 files changed, 254 insertions(+), 175 deletions(-)
diff --git a/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx b/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx
index 336005a8a80..6d2b429bd11 100644
--- a/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx
+++ b/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx
@@ -16,10 +16,55 @@ import {
TypographyType,
} from '../../../components/typography/Typography';
import { usePersonaQuiz } from './persona/usePersonaQuiz';
+import type { DeveloperPersona } from './persona/data';
// Placeholder until the Patchy mascot creative is ready.
const MASCOT_EMOJI = '๐ง';
+type PersonaCardSize = 'medium' | 'small';
+
+interface PersonaCardProps {
+ persona: DeveloperPersona;
+ onSelect: (personaId: string) => void;
+ size?: PersonaCardSize;
+}
+
+const PersonaCard = ({
+ persona,
+ onSelect,
+ size = 'medium',
+}: PersonaCardProps): ReactElement => (
+ onSelect(persona.id)}
+ className="flex flex-col items-center gap-2 rounded-16 border-2 border-border-subtlest-tertiary bg-surface-float p-6 text-center transition-all hover:-translate-y-1 hover:border-accent-cabbage-default tablet:p-8"
+ >
+
+ {persona.emoji}
+
+
+ {persona.name}
+
+
+ {persona.tagline}
+
+
+);
+
function FunnelPersonaQuizComponent({
parameters: { headline, explainer, cta },
onTransition,
@@ -29,6 +74,7 @@ function FunnelPersonaQuizComponent({
questionText,
isThinking,
tiebreakPersonas,
+ triplebreakPersonas,
personas,
result,
isManual,
@@ -158,30 +204,48 @@ function FunnelPersonaQuizComponent({
{tiebreakPersonas.map((persona) => (
- chooseTiebreak(persona.id)}
- className="flex flex-col items-center gap-2 rounded-16 border-2 border-border-subtlest-tertiary bg-surface-float p-8 text-center transition-all hover:-translate-y-1 hover:border-accent-cabbage-default"
- >
-
- {persona.emoji}
-
-
- {persona.name}
-
-
- {persona.tagline}
-
-
+ persona={persona}
+ onSelect={chooseTiebreak}
+ />
+ ))}
+
+
+ );
+ }
+
+ 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) => (
+
))}
+
+ None of these. Let me pick.
+
);
}
diff --git a/packages/shared/src/features/onboarding/steps/persona/data.ts b/packages/shared/src/features/onboarding/steps/persona/data.ts
index 58ad838ac80..e2821586ec3 100644
--- a/packages/shared/src/features/onboarding/steps/persona/data.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/data.ts
@@ -26,6 +26,8 @@ export interface PersonaEngineConfig {
tiebreakThreshold: number;
/** Minimum belief gap between the top two to reveal directly. */
tiebreakMargin: number;
+ /** Below this top belief, offer a three-way pick instead of a two-way tiebreak. */
+ triplebreakFloor: number;
/** Below this top belief, fall back to the generalist persona. */
fallbackFloor: number;
/** Persona id used when belief is too diffuse to call confidently. */
@@ -36,214 +38,191 @@ export interface PersonaEngineConfig {
export const PERSONAS: DeveloperPersona[] = [
{
- id: 'polyglot',
- name: 'The Polyglot',
+ 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: 'web-craftsman',
- name: 'The Web Craftsman',
+ id: 'full-stack-web-developer',
+ name: 'Full-Stack Web Developer',
emoji: 'โ๏ธ',
color: '#06b6d4',
- tagline: 'You ship product. React, TypeScript, the works.',
+ tagline:
+ "React, TypeScript, Node, the works. You ship product.",
},
{
- id: 'frontend-purist',
- name: 'The Frontend Purist',
+ id: 'frontend-specialist',
+ name: 'Frontend Specialist',
emoji: '๐จ',
color: '#ec4899',
- tagline: "Deep in frontend. You know every framework's quirks.",
+ tagline:
+ "Deep in the framework wars. You sweat the details.",
},
{
- id: 'tooling-nerd',
- name: 'The Tooling Nerd',
- emoji: '๐ง',
- color: '#a78bfa',
- tagline: 'VSCode, vim, git, Linux. Your dev environment is sacred.',
+ id: 'ai-app-builder',
+ name: 'AI App Builder',
+ emoji: '๐ช',
+ color: '#8b5cf6',
+ tagline:
+ "You wire LLMs into web apps. Cursor is your IDE.",
},
{
- id: 'model-whisperer',
- name: 'The Model Whisperer',
+ id: 'ai-specialist',
+ name: 'AI Specialist',
emoji: '๐ค',
color: '#22c55e',
- tagline: 'Python, models, evals. You build the AI layer itself.',
- },
- {
- id: 'ai-tinkerer',
- name: 'The AI Tinkerer',
- emoji: '๐ช',
- color: '#8b5cf6',
- tagline: 'You wire LLMs into web apps. Vibe-coding in Cursor.',
+ tagline:
+ "You live in Claude, agents, and RAG pipelines. AI is your work.",
},
{
- id: 'systems-hacker',
- name: 'The Systems Hacker',
- emoji: 'โก',
- color: '#f97316',
- tagline: 'Go, Rust, JVM. You write the safe-but-fast version.',
+ id: 'engineering-leader',
+ name: 'Engineering Leader',
+ emoji: '๐ฐ',
+ color: '#eab308',
+ tagline:
+ "You read more about leadership and trends than your IDE.",
},
{
- id: 'backend-veteran',
- name: 'The Backend Veteran',
+ id: 'backend-developer',
+ name: 'Backend Developer',
emoji: '๐ ๏ธ',
color: '#3b82f6',
- tagline: 'SQL, APIs, databases. You make data move.',
- },
- {
- id: 'industry-sage',
- name: 'The Industry Sage',
- emoji: '๐ฐ',
- color: '#eab308',
- tagline: "You spot trends before they're trends. You lead the team.",
+ tagline:
+ "SQL, APIs, queues, databases. You make the data move.",
},
{
- id: 'architect',
- name: 'The Architect',
+ id: 'software-architect',
+ name: 'Software Architect',
emoji: '๐๏ธ',
color: '#14b8a6',
- tagline: 'Microservices, distributed systems, scale. You draw the boxes.',
+ tagline:
+ "Microservices, distributed systems, scale. You draw the boxes.",
},
{
- id: 'platform-whisperer',
- name: 'The Platform Whisperer',
+ 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. Prod is yours.',
+ 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: 'app-builder',
- name: 'The App Builder',
+ id: 'mobile-developer',
+ name: 'Mobile Developer',
emoji: '๐ฑ',
color: '#f43f5e',
- tagline: 'Native iOS or Android. The store is your stage.',
+ tagline:
+ "iOS, Android, Flutter. The app store is your stage.",
},
];
export const QUESTIONS: PersonaQuestion[] = [
{ text: 'You ship the things users see and click on.', layer: 0 },
{ text: 'Your code runs on servers, not in a browser.', layer: 0 },
- {
- text: 'You read more about the industry than you write code these days.',
- layer: 0,
- },
+ { text: 'You read more about the industry than you write code these days.', layer: 0 },
{ text: 'Your main output is a web app people open in a browser.', layer: 1 },
- { text: "You're faster in a terminal than in any GUI.", layer: 1 },
+ { text: 'You\'re faster in a terminal than in any GUI.', layer: 1 },
{ text: 'You build apps for iPhone or Android.', layer: 1 },
- {
- text: 'Your day involves Jupyter notebooks, datasets, or training runs.',
- layer: 1,
- },
+ { text: 'Your day involves Jupyter notebooks, datasets, or training runs.', layer: 1 },
{ text: 'Your main language is TypeScript or JavaScript.', layer: 2 },
{ text: 'Your main language is Python.', layer: 2 },
{ text: 'Your main language is Go, Rust, or C/C++.', layer: 2 },
- {
- text: 'AI tools are critical to your daily work, not just autocomplete.',
- layer: 2,
- },
- {
- text: "You've shipped code that calls OpenAI, Anthropic, or another LLM API.",
- layer: 2,
- },
- {
- text: "You've spent a weekend customizing your editor or dotfiles.",
- layer: 2,
- },
- {
- text: "You've been the one paged at 3am when production went down.",
- layer: 3,
- },
- { text: "You've fine-tuned an ML model in the last six months.", layer: 2 },
- { 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 know who Stripe, OpenAI, or Anthropic hired last week.',
- layer: 3,
- },
- {
- text: "You specialize in one stack. You don't dabble across many.",
- layer: 2,
- },
- {
- text: "You'd rather read a 30-page postmortem than a quick tutorial.",
- layer: 3,
- },
+ { text: 'AI tools are critical to your daily work, not just autocomplete.', layer: 2 },
+ { text: 'You\'ve shipped code that calls OpenAI, Anthropic, or another LLM API.', layer: 2 },
+ { text: 'You\'ve spent a weekend customizing your editor or dotfiles.', layer: 2 },
+ { text: 'You\'ve fine-tuned an ML model in the last six months.', layer: 2 },
+ { text: 'AI is what you build, not just what you use.', layer: 2 },
{ text: 'You ship web apps with AI features built in.', layer: 2 },
+ { text: 'Your main language is PHP.', layer: 2 },
+ { text: 'Your main stack is C# / .NET.', layer: 2 },
+ { text: 'You\'ve been the one paged at 3am when production went down.', layer: 3 },
+ { 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 could tell who Stripe, OpenAI, or Anthropic hired last week.', 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 },
+ { text: 'Security is your primary job, not a side concern.', layer: 2 },
];
/**
* Likelihood matrix: P[persona][question] = probability a member of that
- * persona answers "yes". Trained offline; rows align with PERSONAS, columns
- * with QUESTIONS.
+ * persona answers "yes". Computed from 90 days of engagement data on
+ * 93,539 active daily.dev users. Rows align with PERSONAS, columns with
+ * QUESTIONS.
*/
export const PERSONA_QUESTION_LIKELIHOOD: number[][] = [
- [
- 0.223, 0.442, 0.0, 0.216, 0.492, 0.007, 0.048, 0.53, 0.193, 0.468, 0.511,
- 0.331, 0.316, 0.261, 0.056, 0.348, 0.293, 0.371, 0.01, 0.0, 0.043,
- ],
- [
- 0.999, 0.189, 0.0, 1.0, 0.221, 0.02, 0.003, 1.0, 0.109, 0.285, 0.231, 0.193,
- 0.193, 0.111, 0.0, 0.243, 0.155, 0.216, 0.008, 0.0, 0.038,
- ],
- [
- 1.0, 0.045, 0.0, 1.0, 0.032, 0.024, 0.007, 1.0, 0.063, 0.083, 0.097, 0.065,
- 0.05, 0.021, 0.014, 0.068, 0.04, 0.058, 0.9, 0.0, 0.076,
- ],
- [
- 0.131, 0.363, 0.018, 0.127, 0.995, 0.012, 0.053, 0.189, 0.207, 0.296, 0.118,
- 0.03, 1.0, 0.066, 0.035, 0.079, 0.052, 0.121, 0.188, 0.037, 0.008,
- ],
- [
- 0.043, 0.145, 0.051, 0.035, 0.16, 0.012, 0.967, 0.112, 0.95, 0.118, 1.0,
- 0.185, 0.165, 0.064, 1.0, 0.104, 0.12, 0.289, 0.204, 0.085, 0.035,
- ],
- [
- 0.919, 0.079, 0.0, 0.926, 0.04, 0.011, 0.382, 0.951, 0.411, 0.116, 0.746,
- 0.367, 0.05, 0.047, 0.521, 0.089, 0.092, 0.176, 0.017, 0.0, 0.878,
- ],
- [
- 0.113, 0.991, 0.0, 0.108, 0.976, 0.015, 0.028, 0.207, 0.137, 0.999, 0.144,
- 0.055, 0.195, 0.126, 0.036, 0.198, 0.243, 0.115, 0.094, 0.014, 0.006,
- ],
- [
- 0.117, 0.152, 0.0, 0.117, 0.12, 0.014, 0.03, 0.16, 0.12, 0.114, 0.081,
- 0.057, 0.087, 0.045, 0.037, 1.0, 0.15, 0.507, 0.163, 0.178, 0.005,
- ],
- [
- 0.115, 0.156, 0.379, 0.122, 0.164, 0.008, 0.106, 0.198, 0.248, 0.118, 0.293,
- 0.071, 0.157, 0.047, 0.133, 0.147, 0.149, 0.999, 0.119, 0.356, 0.029,
- ],
- [
- 0.079, 0.216, 0.1, 0.072, 0.108, 0.019, 0.03, 0.143, 0.119, 0.147, 0.149,
- 0.064, 0.052, 0.079, 0.047, 0.326, 0.963, 0.564, 0.067, 0.0, 0.004,
- ],
- [
- 0.102, 0.996, 0.0, 0.106, 0.959, 0.009, 0.046, 0.197, 0.163, 0.191, 0.159,
- 0.048, 0.207, 0.959, 0.051, 0.213, 0.181, 0.119, 0.131, 0.016, 0.008,
- ],
- [
- 0.999, 0.15, 0.0, 0.335, 0.181, 1.0, 0.028, 0.385, 0.12, 0.148, 0.098,
- 0.036, 0.137, 0.031, 0.038, 0.082, 0.098, 0.076, 0.213, 0.009, 0.026,
- ],
+ [0.189, 0.313, 0.006, 0.147, 0.414, 0.0, 0.028, 0.357, 0.157, 0.328, 0.617, 0.276, 0.285, 0.018, 0.003, 0.032, 0.121, 0.085, 0.185, 0.245, 0.203, 0.238, 0.034, 0.036, 0.153],
+ [1.0, 0.164, 0.0, 1.0, 0.155, 0.0, 0.01, 1.0, 0.081, 0.234, 0.274, 0.294, 0.099, 0.006, 0.0, 0.247, 0.128, 0.051, 0.083, 0.201, 0.115, 0.162, 0.001, 0.026, 0.112],
+ [1.0, 0.043, 0.0, 1.0, 0.033, 0.0, 0.004, 1.0, 0.041, 0.064, 0.112, 0.097, 0.02, 0.002, 0.0, 0.151, 0.054, 0.01, 0.011, 0.046, 0.026, 0.037, 0.84, 0.016, 0.039],
+ [0.889, 0.039, 0.0, 0.896, 0.051, 0.0, 0.004, 0.929, 0.078, 0.126, 0.886, 0.504, 0.055, 0.004, 0.026, 0.87, 0.07, 0.026, 0.055, 0.082, 0.086, 0.129, 0.007, 0.016, 0.069],
+ [0.058, 0.061, 0.069, 0.052, 0.08, 0.0, 0.019, 0.088, 0.131, 0.055, 1.0, 0.168, 0.056, 0.02, 0.885, 0.039, 0.02, 0.012, 0.041, 0.04, 0.063, 0.16, 0.564, 0.009, 0.029],
+ [0.064, 0.121, 0.484, 0.07, 0.074, 0.0, 0.046, 0.119, 0.196, 0.09, 0.661, 0.094, 0.045, 0.044, 0.027, 0.03, 0.032, 0.017, 0.034, 0.145, 0.107, 0.917, 0.04, 0.011, 0.027],
+ [0.09, 0.962, 0.007, 0.099, 0.084, 0.0, 0.015, 0.118, 0.09, 0.079, 0.086, 0.083, 0.036, 0.014, 0.001, 0.005, 0.022, 0.014, 0.036, 0.999, 0.137, 0.459, 0.157, 0.006, 0.013],
+ [0.05, 0.373, 0.192, 0.062, 0.06, 0.0, 0.011, 0.109, 0.086, 0.102, 0.181, 0.097, 0.017, 0.011, 0.0, 0.008, 0.025, 0.037, 0.066, 0.3, 0.909, 0.517, 0.048, 0.005, 0.023],
+ [0.099, 0.966, 0.001, 0.1, 0.948, 0.0, 0.028, 0.169, 0.127, 0.987, 0.182, 0.064, 0.109, 0.024, 0.002, 0.01, 0.03, 0.028, 0.119, 0.158, 0.149, 0.072, 0.065, 0.023, 0.057],
+ [0.069, 0.975, 0.0, 0.07, 0.938, 0.0, 0.021, 0.151, 0.111, 0.171, 0.216, 0.077, 0.104, 0.018, 0.004, 0.006, 0.035, 0.021, 0.943, 0.234, 0.191, 0.099, 0.118, 0.009, 0.107],
+ [0.989, 0.166, 0.0, 1.0, 0.119, 0.0, 0.006, 0.338, 0.062, 0.151, 0.268, 0.146, 0.084, 0.005, 0.001, 0.023, 1.0, 0.027, 0.093, 0.193, 0.075, 0.097, 0.136, 0.012, 0.131],
+ [0.409, 0.927, 0.021, 0.507, 0.162, 0.0, 0.021, 0.507, 0.12, 0.09, 0.139, 0.058, 0.088, 0.012, 0.003, 0.077, 0.038, 0.009, 0.045, 0.062, 0.032, 0.066, 0.135, 0.019, 0.911],
+ [0.097, 0.964, 0.0, 0.097, 0.169, 0.0, 0.006, 0.218, 0.045, 0.158, 0.252, 0.084, 0.131, 0.008, 0.0, 0.009, 0.021, 0.999, 0.073, 0.207, 0.218, 0.09, 0.141, 0.026, 0.068],
+ [0.946, 0.141, 0.001, 0.169, 0.169, 0.0, 0.028, 0.259, 0.132, 0.209, 0.263, 0.064, 0.074, 0.02, 0.0, 0.025, 0.032, 0.068, 0.033, 0.079, 0.05, 0.137, 0.128, 0.968, 0.042],
+ [0.914, 0.151, 0.003, 0.359, 0.119, 1.0, 0.005, 0.376, 0.072, 0.058, 0.231, 0.065, 0.07, 0.005, 0.016, 0.07, 0.032, 0.018, 0.025, 0.096, 0.115, 0.047, 0.142, 0.027, 0.017],
];
-/** Prior probability of each persona, aligned with PERSONAS. */
+/** Prior probability of each persona (log-shaped to balance large and niche personas). */
export const PERSONA_PRIOR: number[] = [
- 0.1876, 0.1673, 0.102, 0.0812, 0.0708, 0.0702, 0.0669, 0.0737, 0.0664, 0.0596,
- 0.0363, 0.0179,
+ 0.2071, 0.1152, 0.0726, 0.0942, 0.0744, 0.0737, 0.0718, 0.0562, 0.0541, 0.0421, 0.0408, 0.025, 0.0237, 0.0176, 0.0317,
];
export const PERSONA_ENGINE_CONFIG: PersonaEngineConfig = {
confidenceThreshold: 0.75,
tiebreakThreshold: 0.5,
tiebreakMargin: 0.07,
- fallbackFloor: 0.15,
- fallbackPersonaId: 'polyglot',
+ triplebreakFloor: 0.3,
+ fallbackFloor: 0.12,
+ fallbackPersonaId: 'generalist-developer',
maxQuestions: 12,
minQuestions: 6,
};
diff --git a/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts b/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
index 05188a936b5..e40e6a9e40e 100644
--- a/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
@@ -14,6 +14,7 @@ export type PersonaQuizPhase =
| 'intro'
| 'playing'
| 'tiebreak'
+ | 'triplebreak'
| 'picker'
| 'reveal';
@@ -24,6 +25,7 @@ const {
confidenceThreshold,
tiebreakThreshold,
tiebreakMargin,
+ triplebreakFloor,
fallbackFloor,
fallbackPersonaId,
maxQuestions,
@@ -45,6 +47,7 @@ export interface PersonaQuizState {
progress: number;
isThinking: boolean;
tiebreakPersonas: DeveloperPersona[];
+ triplebreakPersonas: DeveloperPersona[];
personas: DeveloperPersona[];
result: PersonaResult | null;
/** True when the user picked their persona instead of playing the quiz. */
@@ -64,7 +67,7 @@ export const usePersonaQuiz = (): PersonaQuizState => {
const [currentQuestion, setCurrentQuestion] = useState(null);
const [questionsShown, setQuestionsShown] = useState(0);
const [isThinking, setIsThinking] = useState(false);
- const [tiebreak, setTiebreak] = useState<[number, number] | null>(null);
+ const [tiebreak, setTiebreak] = useState(null);
const [resultIndex, setResultIndex] = useState(null);
const [isManual, setIsManual] = useState(false);
@@ -80,8 +83,12 @@ export const usePersonaQuiz = (): PersonaQuizState => {
const finish = useCallback(
(nextBelief: number[]) => {
- const [top, runnerUp] = rankBelief(nextBelief);
+ const ranked = rankBelief(nextBelief);
+ const top = ranked[0];
+ const runnerUp = ranked[1];
+ const third = ranked[2];
+ // 1. Confident top-1: reveal directly.
if (
top.belief >= tiebreakThreshold &&
top.belief - runnerUp.belief >= tiebreakMargin
@@ -90,14 +97,33 @@ export const usePersonaQuiz = (): PersonaQuizState => {
return;
}
+ // 2. Belief is too diffuse: fall back to the generalist.
if (top.belief < fallbackFloor) {
reveal(FALLBACK_PERSONA_INDEX, nextBelief);
return;
}
- setTiebreak([top.index, runnerUp.index]);
- setBelief(nextBelief);
- setPhase('tiebreak');
+ // 3. Belief is moderate but not decisive: offer a two-way pick.
+ if (top.belief >= triplebreakFloor && runnerUp) {
+ setTiebreak([top.index, runnerUp.index]);
+ setBelief(nextBelief);
+ setPhase('tiebreak');
+ return;
+ }
+
+ // 4. Belief is low but above fallback: offer a 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;
+ }
+
+ reveal(top.index, nextBelief);
},
[reveal],
);
@@ -202,10 +228,19 @@ export const usePersonaQuiz = (): PersonaQuizState => {
askedRef.current = new Set();
}, []);
- const tiebreakPersonas = useMemo(
- () => (tiebreak ? tiebreak.map((index) => PERSONAS[index]) : []),
- [tiebreak],
- );
+ 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) {
@@ -226,6 +261,7 @@ export const usePersonaQuiz = (): PersonaQuizState => {
progress: Math.min(questionsShown / maxQuestions, 1),
isThinking,
tiebreakPersonas,
+ triplebreakPersonas,
personas: PERSONAS,
result,
isManual,
From fb540b06b4680e51f7fbb5aec90f8f1f2935835f Mon Sep 17 00:00:00 2001
From: idoshamun <16071328+idoshamun@users.noreply.github.com>
Date: Wed, 3 Jun 2026 19:03:45 +0000
Subject: [PATCH 03/15] feat(onboarding): hard locks on self-id questions +
Q2/Q22 rewording
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
User-reported issues from internal playtest:
1. Q2 'Your code runs on servers, not in a browser' was confusing
for mobile and game devs โ their code does not run on servers
but is also not in a browser, leaving them without a clean
answer.
2. Q22 'You could tell who Stripe, OpenAI or Anthropic hired last
week' read as random trivia and not a real signal.
3. Answering yes to 'Security is your primary job' still landed
on Generalist or Backend. Same for 'You build games' โ the
quiz kept asking unrelated AI questions before revealing.
Fixes:
Question rewording (data.ts):
- Q2: 'Your work is mostly backend or infrastructure, not
frontend or mobile.' Mobile and game devs now have a clean
no instead of a forced compromise.
- Q22: 'You actively follow tech industry news (deals, hiring,
leadership).' Same intent, less trivia-flavoured.
Hard-lock questions (data + engine):
- PersonaQuestion gained an optional lockPersonaId. When the
user answers yes to a lock question, the engine reveals that
persona immediately, regardless of accumulated Bayesian belief.
Six questions now lock:
Q6 iPhone or Android -> Mobile Developer
Q15 AI is what you build -> AI Specialist
Q17 main language is PHP -> PHP Developer
Q18 main stack is .NET -> .NET Developer
Q24 you build games -> Game Developer
Q25 security is your job -> Security Engineer
These are self-identification questions: if the user agrees,
there is no signal stronger than self-report. Trust it over
the matrix.
Instant-lock fallback (engine):
- New instantLockThreshold (0.85) and instantLockMargin (0.5)
in PERSONA_ENGINE_CONFIG. When belief becomes overwhelming
(one persona above 0.85 and at least 0.5 ahead of the runner
up), the quiz now reveals immediately without waiting for
minQuestions. Prevents the 'I told you I build games, why
are you still asking?' problem when the hard lock did not
trigger but the persona is otherwise clearly identified.
Matrix and prior regenerated (data.ts):
- Tightened the behavioural proxies behind Q17, Q18, Q24 and
Q25 to require a topic-share signal (>= 0.25-0.30) instead
of also accepting raw tag counts. This collapses the
Generalist false-positive rate on those columns from 12-15%
down to roughly 0%, so the matrix backs the hard locks
instead of fighting them. Lock-question precision stays
high (PHP 88%, .NET 83%, Games 76%, Security 68% true-
positive on the proxy).
---
.../features/onboarding/steps/persona/data.ts | 59 +++++++++++--------
.../steps/persona/usePersonaQuiz.ts | 40 +++++++++++--
2 files changed, 72 insertions(+), 27 deletions(-)
diff --git a/packages/shared/src/features/onboarding/steps/persona/data.ts b/packages/shared/src/features/onboarding/steps/persona/data.ts
index e2821586ec3..bef97a5493c 100644
--- a/packages/shared/src/features/onboarding/steps/persona/data.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/data.ts
@@ -17,6 +17,13 @@ export interface PersonaQuestion {
* the belief narrows down.
*/
layer: number;
+ /**
+ * When set, a yes answer is treated as a hard self-identification:
+ * the engine reveals this persona immediately, regardless of the
+ * current belief. Used for niche-locking questions where the answer
+ * IS the persona (e.g. 'Security is your primary job').
+ */
+ lockPersonaId?: string;
}
export interface PersonaEngineConfig {
@@ -34,6 +41,10 @@ export interface PersonaEngineConfig {
fallbackPersonaId: string;
maxQuestions: number;
minQuestions: number;
+ /** Top belief required to instantly reveal, overriding minQuestions. */
+ instantLockThreshold: number;
+ /** Minimum margin between top two beliefs for the instant lock to fire. */
+ instantLockMargin: number;
}
export const PERSONAS: DeveloperPersona[] = [
@@ -161,11 +172,11 @@ export const PERSONAS: DeveloperPersona[] = [
export const QUESTIONS: PersonaQuestion[] = [
{ text: 'You ship the things users see and click on.', layer: 0 },
- { text: 'Your code runs on servers, not in a browser.', layer: 0 },
+ { text: 'Your work is mostly backend or infrastructure, not frontend or mobile.', layer: 0 },
{ text: 'You read more about the industry than you write code these days.', layer: 0 },
{ text: 'Your main output is a web app people open in a browser.', layer: 1 },
{ text: 'You\'re faster in a terminal than in any GUI.', layer: 1 },
- { text: 'You build apps for iPhone or Android.', layer: 1 },
+ { text: 'You build apps for iPhone or Android.', layer: 1, lockPersonaId: 'mobile-developer' },
{ text: 'Your day involves Jupyter notebooks, datasets, or training runs.', layer: 1 },
{ text: 'Your main language is TypeScript or JavaScript.', layer: 2 },
{ text: 'Your main language is Python.', layer: 2 },
@@ -174,17 +185,17 @@ export const QUESTIONS: PersonaQuestion[] = [
{ text: 'You\'ve shipped code that calls OpenAI, Anthropic, or another LLM API.', layer: 2 },
{ text: 'You\'ve spent a weekend customizing your editor or dotfiles.', layer: 2 },
{ text: 'You\'ve fine-tuned an ML model in the last six months.', layer: 2 },
- { text: 'AI is what you build, not just what you use.', layer: 2 },
+ { text: 'AI is what you build, not just what you use.', layer: 2, lockPersonaId: 'ai-specialist' },
{ text: 'You ship web apps with AI features built in.', layer: 2 },
- { text: 'Your main language is PHP.', layer: 2 },
- { text: 'Your main stack is C# / .NET.', layer: 2 },
+ { text: 'Your main language is PHP.', layer: 2, lockPersonaId: 'php-developer' },
+ { text: 'Your main stack is C# / .NET.', layer: 2, lockPersonaId: 'dotnet-developer' },
{ text: 'You\'ve been the one paged at 3am when production went down.', layer: 3 },
{ 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 could tell who Stripe, OpenAI, or Anthropic hired last week.', layer: 3 },
+ { text: 'You actively follow tech industry news (deals, hiring, leadership).', 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 },
- { text: 'Security is your primary job, not a side concern.', 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' },
];
/**
@@ -194,21 +205,21 @@ export const QUESTIONS: PersonaQuestion[] = [
* QUESTIONS.
*/
export const PERSONA_QUESTION_LIKELIHOOD: number[][] = [
- [0.189, 0.313, 0.006, 0.147, 0.414, 0.0, 0.028, 0.357, 0.157, 0.328, 0.617, 0.276, 0.285, 0.018, 0.003, 0.032, 0.121, 0.085, 0.185, 0.245, 0.203, 0.238, 0.034, 0.036, 0.153],
- [1.0, 0.164, 0.0, 1.0, 0.155, 0.0, 0.01, 1.0, 0.081, 0.234, 0.274, 0.294, 0.099, 0.006, 0.0, 0.247, 0.128, 0.051, 0.083, 0.201, 0.115, 0.162, 0.001, 0.026, 0.112],
- [1.0, 0.043, 0.0, 1.0, 0.033, 0.0, 0.004, 1.0, 0.041, 0.064, 0.112, 0.097, 0.02, 0.002, 0.0, 0.151, 0.054, 0.01, 0.011, 0.046, 0.026, 0.037, 0.84, 0.016, 0.039],
- [0.889, 0.039, 0.0, 0.896, 0.051, 0.0, 0.004, 0.929, 0.078, 0.126, 0.886, 0.504, 0.055, 0.004, 0.026, 0.87, 0.07, 0.026, 0.055, 0.082, 0.086, 0.129, 0.007, 0.016, 0.069],
- [0.058, 0.061, 0.069, 0.052, 0.08, 0.0, 0.019, 0.088, 0.131, 0.055, 1.0, 0.168, 0.056, 0.02, 0.885, 0.039, 0.02, 0.012, 0.041, 0.04, 0.063, 0.16, 0.564, 0.009, 0.029],
- [0.064, 0.121, 0.484, 0.07, 0.074, 0.0, 0.046, 0.119, 0.196, 0.09, 0.661, 0.094, 0.045, 0.044, 0.027, 0.03, 0.032, 0.017, 0.034, 0.145, 0.107, 0.917, 0.04, 0.011, 0.027],
- [0.09, 0.962, 0.007, 0.099, 0.084, 0.0, 0.015, 0.118, 0.09, 0.079, 0.086, 0.083, 0.036, 0.014, 0.001, 0.005, 0.022, 0.014, 0.036, 0.999, 0.137, 0.459, 0.157, 0.006, 0.013],
- [0.05, 0.373, 0.192, 0.062, 0.06, 0.0, 0.011, 0.109, 0.086, 0.102, 0.181, 0.097, 0.017, 0.011, 0.0, 0.008, 0.025, 0.037, 0.066, 0.3, 0.909, 0.517, 0.048, 0.005, 0.023],
- [0.099, 0.966, 0.001, 0.1, 0.948, 0.0, 0.028, 0.169, 0.127, 0.987, 0.182, 0.064, 0.109, 0.024, 0.002, 0.01, 0.03, 0.028, 0.119, 0.158, 0.149, 0.072, 0.065, 0.023, 0.057],
- [0.069, 0.975, 0.0, 0.07, 0.938, 0.0, 0.021, 0.151, 0.111, 0.171, 0.216, 0.077, 0.104, 0.018, 0.004, 0.006, 0.035, 0.021, 0.943, 0.234, 0.191, 0.099, 0.118, 0.009, 0.107],
- [0.989, 0.166, 0.0, 1.0, 0.119, 0.0, 0.006, 0.338, 0.062, 0.151, 0.268, 0.146, 0.084, 0.005, 0.001, 0.023, 1.0, 0.027, 0.093, 0.193, 0.075, 0.097, 0.136, 0.012, 0.131],
- [0.409, 0.927, 0.021, 0.507, 0.162, 0.0, 0.021, 0.507, 0.12, 0.09, 0.139, 0.058, 0.088, 0.012, 0.003, 0.077, 0.038, 0.009, 0.045, 0.062, 0.032, 0.066, 0.135, 0.019, 0.911],
- [0.097, 0.964, 0.0, 0.097, 0.169, 0.0, 0.006, 0.218, 0.045, 0.158, 0.252, 0.084, 0.131, 0.008, 0.0, 0.009, 0.021, 0.999, 0.073, 0.207, 0.218, 0.09, 0.141, 0.026, 0.068],
- [0.946, 0.141, 0.001, 0.169, 0.169, 0.0, 0.028, 0.259, 0.132, 0.209, 0.263, 0.064, 0.074, 0.02, 0.0, 0.025, 0.032, 0.068, 0.033, 0.079, 0.05, 0.137, 0.128, 0.968, 0.042],
- [0.914, 0.151, 0.003, 0.359, 0.119, 1.0, 0.005, 0.376, 0.072, 0.058, 0.231, 0.065, 0.07, 0.005, 0.016, 0.07, 0.032, 0.018, 0.025, 0.096, 0.115, 0.047, 0.142, 0.027, 0.017],
+ [0.189, 0.273, 0.006, 0.147, 0.414, 0.0, 0.028, 0.357, 0.157, 0.328, 0.617, 0.276, 0.285, 0.018, 0.003, 0.032, 0.002, 0.001, 0.185, 0.245, 0.203, 0.238, 0.034, 0.001, 0.003],
+ [1.0, 0.0, 0.0, 1.0, 0.155, 0.0, 0.01, 1.0, 0.081, 0.234, 0.274, 0.294, 0.099, 0.006, 0.0, 0.247, 0.008, 0.002, 0.083, 0.201, 0.115, 0.162, 0.001, 0.002, 0.0],
+ [1.0, 0.0, 0.0, 1.0, 0.033, 0.0, 0.004, 1.0, 0.041, 0.064, 0.112, 0.097, 0.02, 0.002, 0.0, 0.151, 0.012, 0.003, 0.011, 0.046, 0.026, 0.037, 0.84, 0.006, 0.006],
+ [0.889, 0.006, 0.0, 0.896, 0.051, 0.0, 0.004, 0.929, 0.078, 0.126, 0.886, 0.504, 0.055, 0.004, 0.026, 0.87, 0.002, 0.0, 0.055, 0.082, 0.086, 0.129, 0.007, 0.001, 0.0],
+ [0.058, 0.06, 0.069, 0.052, 0.08, 0.0, 0.019, 0.088, 0.131, 0.055, 1.0, 0.168, 0.056, 0.02, 0.885, 0.039, 0.007, 0.004, 0.041, 0.04, 0.063, 0.16, 0.564, 0.002, 0.006],
+ [0.064, 0.119, 0.484, 0.07, 0.074, 0.0, 0.046, 0.119, 0.196, 0.09, 0.661, 0.094, 0.045, 0.044, 0.027, 0.03, 0.006, 0.002, 0.034, 0.145, 0.107, 0.917, 0.04, 0.001, 0.004],
+ [0.09, 0.87, 0.007, 0.099, 0.084, 0.0, 0.015, 0.118, 0.09, 0.079, 0.086, 0.083, 0.036, 0.014, 0.001, 0.005, 0.006, 0.002, 0.036, 0.999, 0.137, 0.459, 0.157, 0.002, 0.001],
+ [0.05, 0.365, 0.192, 0.062, 0.06, 0.0, 0.011, 0.109, 0.086, 0.102, 0.181, 0.097, 0.017, 0.011, 0.0, 0.008, 0.005, 0.002, 0.066, 0.3, 0.909, 0.517, 0.048, 0.001, 0.001],
+ [0.099, 0.854, 0.001, 0.1, 0.948, 0.0, 0.028, 0.169, 0.127, 0.987, 0.182, 0.064, 0.109, 0.024, 0.002, 0.01, 0.005, 0.001, 0.119, 0.158, 0.149, 0.072, 0.065, 0.002, 0.002],
+ [0.069, 0.908, 0.0, 0.07, 0.938, 0.0, 0.021, 0.151, 0.111, 0.171, 0.216, 0.077, 0.104, 0.018, 0.004, 0.006, 0.004, 0.001, 0.943, 0.234, 0.191, 0.099, 0.118, 0.001, 0.008],
+ [0.989, 0.154, 0.0, 1.0, 0.119, 0.0, 0.006, 0.338, 0.062, 0.151, 0.268, 0.146, 0.084, 0.005, 0.001, 0.023, 0.879, 0.001, 0.093, 0.193, 0.075, 0.097, 0.136, 0.003, 0.004],
+ [0.409, 0.465, 0.021, 0.507, 0.162, 0.0, 0.021, 0.507, 0.12, 0.09, 0.139, 0.058, 0.088, 0.012, 0.003, 0.077, 0.009, 0.001, 0.045, 0.062, 0.032, 0.066, 0.135, 0.008, 0.683],
+ [0.097, 0.841, 0.0, 0.097, 0.169, 0.0, 0.006, 0.218, 0.045, 0.158, 0.252, 0.084, 0.131, 0.008, 0.0, 0.009, 0.003, 0.825, 0.073, 0.207, 0.218, 0.09, 0.141, 0.008, 0.004],
+ [0.946, 0.002, 0.001, 0.169, 0.169, 0.0, 0.028, 0.259, 0.132, 0.209, 0.263, 0.064, 0.074, 0.02, 0.0, 0.025, 0.006, 0.01, 0.033, 0.079, 0.05, 0.137, 0.128, 0.763, 0.002],
+ [0.914, 0.0, 0.003, 0.359, 0.119, 1.0, 0.005, 0.376, 0.072, 0.058, 0.231, 0.065, 0.07, 0.005, 0.016, 0.07, 0.015, 0.005, 0.025, 0.096, 0.115, 0.047, 0.142, 0.016, 0.004],
];
/** Prior probability of each persona (log-shaped to balance large and niche personas). */
@@ -225,4 +236,6 @@ export const PERSONA_ENGINE_CONFIG: PersonaEngineConfig = {
fallbackPersonaId: 'generalist-developer',
maxQuestions: 12,
minQuestions: 6,
+ instantLockThreshold: 0.85,
+ instantLockMargin: 0.5,
};
diff --git a/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts b/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
index e40e6a9e40e..5fb6080f59b 100644
--- a/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
@@ -30,6 +30,8 @@ const {
fallbackPersonaId,
maxQuestions,
minQuestions,
+ instantLockThreshold,
+ instantLockMargin,
} = PERSONA_ENGINE_CONFIG;
const FALLBACK_PERSONA_INDEX = personaIndexById(fallbackPersonaId);
@@ -130,11 +132,20 @@ export const usePersonaQuiz = (): PersonaQuizState => {
const advance = useCallback(
(nextBelief: number[], shownSoFar: number) => {
- const topBelief = Math.max(...nextBelief);
+ const ranked = rankBelief(nextBelief);
+ const top = ranked[0]?.belief ?? 0;
+ const margin = top - (ranked[1]?.belief ?? 0);
const reachedConfidence =
- topBelief >= confidenceThreshold && shownSoFar >= minQuestions;
+ top >= confidenceThreshold && shownSoFar >= minQuestions;
+ // Instant lock: when one persona overwhelmingly dominates, reveal early.
+ const reachedInstantLock =
+ top >= instantLockThreshold && margin >= instantLockMargin;
- if (shownSoFar >= maxQuestions || reachedConfidence) {
+ if (
+ shownSoFar >= maxQuestions ||
+ reachedConfidence ||
+ reachedInstantLock
+ ) {
finish(nextBelief);
return;
}
@@ -171,16 +182,37 @@ export const usePersonaQuiz = (): PersonaQuizState => {
return;
}
+ const question = QUESTIONS[currentQuestion];
const nextBelief = updateBelief(belief, currentQuestion, value);
setBelief(nextBelief);
setIsThinking(true);
thinkingTimeout.current = setTimeout(() => {
setIsThinking(false);
+
+ // Hard lock: when a self-identification question is answered yes,
+ // trust the user and reveal that persona regardless of belief.
+ if (value === 1 && question.lockPersonaId) {
+ const lockIndex = PERSONAS.findIndex(
+ (persona) => persona.id === question.lockPersonaId,
+ );
+ if (lockIndex >= 0) {
+ reveal(lockIndex, nextBelief);
+ return;
+ }
+ }
+
advance(nextBelief, questionsShown);
}, THINKING_DURATION_MS);
},
- [advance, belief, currentQuestion, isThinking, questionsShown],
+ [
+ advance,
+ belief,
+ currentQuestion,
+ isThinking,
+ questionsShown,
+ reveal,
+ ],
);
const chooseTiebreak = useCallback(
From 47c875b531c0584ba254705391b64324669a7bc3 Mon Sep 17 00:00:00 2001
From: idoshamun <16071328+idoshamun@users.noreply.github.com>
Date: Wed, 3 Jun 2026 19:13:22 +0000
Subject: [PATCH 04/15] feat(onboarding): skip contradictory main-language
questions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
User feedback from playtest: the quiz would ask 'Your main language
is TypeScript or JavaScript', then a few questions later ask 'Your
main language is Python' (or Go/Rust/PHP/.NET). Saying yes to one
should rule out the others โ they describe the same single attribute.
Adds an exclusiveGroup label to PersonaQuestion. Every 'main language'
question now belongs to the same 'main-language' group:
Q8 TypeScript / JavaScript
Q9 Python
Q10 Go / Rust / C / C++
Q17 PHP
Q18 C# / .NET
The hook keeps an excludedGroupsRef set. When a question with an
exclusiveGroup is answered yes, the group is closed out for the rest
of the session, and pickNextQuestion in engine.ts skips any question
that belongs to a closed group. PHP and .NET still lock immediately
on yes via lockPersonaId, so this mostly affects the JS/Python/Go
trio โ those three no longer get asked in sequence once one is
confirmed.
restart() and start() also reset the group set so a replay does not
inherit the previous run's exclusions.
---
.../features/onboarding/steps/persona/data.ts | 18 ++++++++---
.../onboarding/steps/persona/engine.ts | 11 ++++++-
.../steps/persona/usePersonaQuiz.ts | 31 ++++++++++++-------
3 files changed, 42 insertions(+), 18 deletions(-)
diff --git a/packages/shared/src/features/onboarding/steps/persona/data.ts b/packages/shared/src/features/onboarding/steps/persona/data.ts
index bef97a5493c..f954d4b56b0 100644
--- a/packages/shared/src/features/onboarding/steps/persona/data.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/data.ts
@@ -24,6 +24,14 @@ export interface PersonaQuestion {
* IS the persona (e.g. 'Security is your primary job').
*/
lockPersonaId?: string;
+ /**
+ * When set, this question is mutually exclusive with every other
+ * question that shares the same group label. Once any of them is
+ * answered yes, the engine stops asking the rest. Used for the
+ * 'Your main language is X' questions where the user can only
+ * truthfully say yes once.
+ */
+ exclusiveGroup?: string;
}
export interface PersonaEngineConfig {
@@ -178,17 +186,17 @@ export const QUESTIONS: PersonaQuestion[] = [
{ 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' },
{ text: 'Your day involves Jupyter notebooks, datasets, or training runs.', layer: 1 },
- { text: 'Your main language is TypeScript or JavaScript.', layer: 2 },
- { text: 'Your main language is Python.', layer: 2 },
- { text: 'Your main language is Go, Rust, or C/C++.', layer: 2 },
+ { 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: 'AI tools are critical to your daily work, not just autocomplete.', layer: 2 },
{ text: 'You\'ve shipped code that calls OpenAI, Anthropic, or another LLM API.', layer: 2 },
{ text: 'You\'ve spent a weekend customizing your editor or dotfiles.', layer: 2 },
{ text: 'You\'ve fine-tuned an ML model in the last six months.', layer: 2 },
{ text: 'AI is what you build, not just what you use.', layer: 2, lockPersonaId: 'ai-specialist' },
{ text: 'You ship web apps with AI features built in.', layer: 2 },
- { text: 'Your main language is PHP.', layer: 2, lockPersonaId: 'php-developer' },
- { text: 'Your main stack is C# / .NET.', layer: 2, lockPersonaId: 'dotnet-developer' },
+ { 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: 'You\'ve been the one paged at 3am when production went down.', layer: 3 },
{ text: 'You write more SQL than CSS.', layer: 3 },
{ text: 'You\'ve drawn boxes and arrows on a whiteboard this month.', layer: 3 },
diff --git a/packages/shared/src/features/onboarding/steps/persona/engine.ts b/packages/shared/src/features/onboarding/steps/persona/engine.ts
index 8dedae428d4..756316b1471 100644
--- a/packages/shared/src/features/onboarding/steps/persona/engine.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/engine.ts
@@ -84,11 +84,17 @@ const allowedLayers = (questionsShown: number): Set => {
return new Set([0, 1, 2, 3]);
};
-/** Returns the next best question index, or -1 when none remain. */
+/**
+ * Returns the next best question index, or -1 when none remain.
+ * Questions whose exclusiveGroup has been answered yes already are skipped:
+ * once the user picks their main language, asking about the other languages
+ * is contradictory and wastes a slot.
+ */
export const pickNextQuestion = (
belief: number[],
asked: Set,
questionsShown: number,
+ excludedGroups: Set = new Set(),
): number => {
const layers = allowedLayers(questionsShown);
@@ -97,6 +103,9 @@ export const pickNextQuestion = (
if (asked.has(q) || !layers.has(question.layer)) {
return best;
}
+ if (question.exclusiveGroup && excludedGroups.has(question.exclusiveGroup)) {
+ return best;
+ }
const gain = informationGain(belief, q);
return gain > best.gain ? { index: q, gain } : best;
},
diff --git a/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts b/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
index 5fb6080f59b..a3000ad13cd 100644
--- a/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
@@ -74,6 +74,7 @@ export const usePersonaQuiz = (): PersonaQuizState => {
const [isManual, setIsManual] = useState(false);
const askedRef = useRef>(new Set());
+ const excludedGroupsRef = useRef>(new Set());
const thinkingTimeout = useRef>();
const reveal = useCallback((index: number, nextBelief: number[]) => {
@@ -137,7 +138,6 @@ export const usePersonaQuiz = (): PersonaQuizState => {
const margin = top - (ranked[1]?.belief ?? 0);
const reachedConfidence =
top >= confidenceThreshold && shownSoFar >= minQuestions;
- // Instant lock: when one persona overwhelmingly dominates, reveal early.
const reachedInstantLock =
top >= instantLockThreshold && margin >= instantLockMargin;
@@ -150,7 +150,12 @@ export const usePersonaQuiz = (): PersonaQuizState => {
return;
}
- const next = pickNextQuestion(nextBelief, askedRef.current, shownSoFar);
+ const next = pickNextQuestion(
+ nextBelief,
+ askedRef.current,
+ shownSoFar,
+ excludedGroupsRef.current,
+ );
if (next < 0) {
finish(nextBelief);
return;
@@ -165,6 +170,7 @@ export const usePersonaQuiz = (): PersonaQuizState => {
const start = useCallback(() => {
askedRef.current = new Set();
+ excludedGroupsRef.current = new Set();
const fresh = initialBelief();
setBelief(fresh);
setResultIndex(null);
@@ -187,11 +193,18 @@ export const usePersonaQuiz = (): PersonaQuizState => {
setBelief(nextBelief);
setIsThinking(true);
+ // Once the user commits to a mutually-exclusive question (e.g. "main
+ // language is JS/TS"), close out the entire exclusive group so the
+ // engine stops asking contradictory follow-ups.
+ if (value === 1 && question.exclusiveGroup) {
+ excludedGroupsRef.current.add(question.exclusiveGroup);
+ }
+
thinkingTimeout.current = setTimeout(() => {
setIsThinking(false);
- // Hard lock: when a self-identification question is answered yes,
- // trust the user and reveal that persona regardless of belief.
+ // Hard lock: a yes to a self-identification question reveals that
+ // persona immediately, regardless of the current belief.
if (value === 1 && question.lockPersonaId) {
const lockIndex = PERSONAS.findIndex(
(persona) => persona.id === question.lockPersonaId,
@@ -205,14 +218,7 @@ export const usePersonaQuiz = (): PersonaQuizState => {
advance(nextBelief, questionsShown);
}, THINKING_DURATION_MS);
},
- [
- advance,
- belief,
- currentQuestion,
- isThinking,
- questionsShown,
- reveal,
- ],
+ [advance, belief, currentQuestion, isThinking, questionsShown, reveal],
);
const chooseTiebreak = useCallback(
@@ -258,6 +264,7 @@ export const usePersonaQuiz = (): PersonaQuizState => {
setResultIndex(null);
setIsManual(false);
askedRef.current = new Set();
+ excludedGroupsRef.current = new Set();
}, []);
const tiebreakPersonas = useMemo(() => {
From c32ca5407ab80e2732e67cbd543efe4378195d48 Mon Sep 17 00:00:00 2001
From: idoshamun <16071328+idoshamun@users.noreply.github.com>
Date: Wed, 3 Jun 2026 19:16:53 +0000
Subject: [PATCH 05/15] feat(onboarding): close primary-domain group on Q1/Q2
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Q1 ("You ship the things users see and click on") and Q2 ("Your work
is mostly backend or infrastructure, not frontend or mobile") sit on
opposite halves of the same axis. The explicit "not frontend or
mobile" in Q2 makes them mutually exclusive: a yes on either implies
no on the other.
Engine info-gain usually skips the second one after the first is
answered yes, but if belief stays diffuse the user could still see
both, which reads as contradictory. Tagging both with the same
exclusiveGroup ('primary-domain') guarantees only one is ever asked.
Audited the rest of the bank โ every other near-conflict either:
- already short-circuits via lockPersonaId (mobile, AI, PHP, .NET,
game, security),
- is a hierarchy not an exclusion (Q11/Q15, Q12/Q16), or
- is genuinely compatible (Q4/Q5, Q24/Q6, etc).
---
packages/shared/src/features/onboarding/steps/persona/data.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/shared/src/features/onboarding/steps/persona/data.ts b/packages/shared/src/features/onboarding/steps/persona/data.ts
index f954d4b56b0..f921e18c85d 100644
--- a/packages/shared/src/features/onboarding/steps/persona/data.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/data.ts
@@ -179,8 +179,8 @@ export const PERSONAS: DeveloperPersona[] = [
];
export const QUESTIONS: PersonaQuestion[] = [
- { text: 'You ship the things users see and click on.', layer: 0 },
- { text: 'Your work is mostly backend or infrastructure, not frontend or mobile.', layer: 0 },
+ { text: 'You ship the things users see and click on.', layer: 0, exclusiveGroup: 'primary-domain' },
+ { text: 'Your work is mostly backend or infrastructure, not frontend or mobile.', layer: 0, exclusiveGroup: 'primary-domain' },
{ text: 'You read more about the industry than you write code these days.', layer: 0 },
{ text: 'Your main output is a web app people open in a browser.', layer: 1 },
{ text: 'You\'re faster in a terminal than in any GUI.', layer: 1 },
From 3154ae3d4d36f2ea5ff9c9780657499d64ad98e2 Mon Sep 17 00:00:00 2001
From: idoshamun <16071328+idoshamun@users.noreply.github.com>
Date: Wed, 3 Jun 2026 20:17:17 +0000
Subject: [PATCH 06/15] feat(onboarding): reword Q11 to be more concrete
Old wording 'AI tools are critical to your daily work, not just
autocomplete' was vague: 'critical' is subjective and the negation
read as a parenthetical afterthought. New wording names the actual
tools and frames the threshold as active use vs passive autocomplete:
'You actively use AI tools (Cursor, Claude, Copilot) to do real
work, not just for autocomplete.'
This separates 'Copilot pushes me suggestions and I accept them'
(everyone) from 'I direct AI tools to do meaningful work' (AI App
Builder, AI Specialist, indie hackers).
---
packages/shared/src/features/onboarding/steps/persona/data.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/shared/src/features/onboarding/steps/persona/data.ts b/packages/shared/src/features/onboarding/steps/persona/data.ts
index f921e18c85d..4efc6e6a8b0 100644
--- a/packages/shared/src/features/onboarding/steps/persona/data.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/data.ts
@@ -189,7 +189,7 @@ export const QUESTIONS: PersonaQuestion[] = [
{ 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: 'AI tools are critical to your daily work, not just autocomplete.', layer: 2 },
+ { text: 'You actively use AI tools (Cursor, Claude, Copilot) to do real work, not just for autocomplete.', layer: 2 },
{ text: 'You\'ve shipped code that calls OpenAI, Anthropic, or another LLM API.', layer: 2 },
{ text: 'You\'ve spent a weekend customizing your editor or dotfiles.', layer: 2 },
{ text: 'You\'ve fine-tuned an ML model in the last six months.', layer: 2 },
From ff3150989a4187f90770baec93d4bc810f451f3a Mon Sep 17 00:00:00 2001
From: idoshamun <16071328+idoshamun@users.noreply.github.com>
Date: Wed, 3 Jun 2026 20:18:56 +0000
Subject: [PATCH 07/15] feat(onboarding): reframe Q11 around scope of AI work
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Previous wording named Cursor, Claude, and Copilot together, but
Copilot is fundamentally an autocomplete product โ listing it next
to agentic tools made the 'not just autocomplete' caveat read as a
contradiction (everyone who uses Copilot would honestly say yes).
The signal we actually want is whether the user lets AI do
meaningful chunks of their work, not whether they happen to use a
specific tool. Reframed around scope:
'You let AI write whole functions or features for you, not just
autocomplete suggestions.'
This separates 'AI completes the line I was going to write' from
'I give AI a task and use what it produces', which is the actual
behavioural divide between casual Copilot users and AI App
Builders / AI Specialists.
---
packages/shared/src/features/onboarding/steps/persona/data.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/shared/src/features/onboarding/steps/persona/data.ts b/packages/shared/src/features/onboarding/steps/persona/data.ts
index 4efc6e6a8b0..6d8fec21c4f 100644
--- a/packages/shared/src/features/onboarding/steps/persona/data.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/data.ts
@@ -189,7 +189,7 @@ export const QUESTIONS: PersonaQuestion[] = [
{ 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: 'You actively use AI tools (Cursor, Claude, Copilot) to do real work, not just for autocomplete.', layer: 2 },
+ { text: 'You let AI write whole functions or features for you, not just autocomplete suggestions.', layer: 2 },
{ text: 'You\'ve shipped code that calls OpenAI, Anthropic, or another LLM API.', layer: 2 },
{ text: 'You\'ve spent a weekend customizing your editor or dotfiles.', layer: 2 },
{ text: 'You\'ve fine-tuned an ML model in the last six months.', layer: 2 },
From 0da8478d06b14dddb13486621a0b19d60e4ba2da Mon Sep 17 00:00:00 2001
From: idoshamun <16071328+idoshamun@users.noreply.github.com>
Date: Wed, 3 Jun 2026 20:21:11 +0000
Subject: [PATCH 08/15] feat(onboarding): reframe Q11 around agentic AI use
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Previous wording ('You let AI write whole functions or features
for you, not just autocomplete suggestions') is no longer a real
discriminator: in 2026 essentially every developer lets AI write
functions for them via Cursor, Claude chat, or ChatGPT.
The signal we actually want to isolate is the leap from chat/
autocomplete to agentic AI work โ letting an AI agent run for
minutes on a multi-step task and reviewing the result. That is
the behavioural divide that separates AI App Builders and AI
Specialists from the broad population that 'uses AI for code'.
New wording:
'You use agentic AI tools (Claude Code, Cursor Agent) that run
on tasks autonomously, not just chat or autocomplete.'
---
packages/shared/src/features/onboarding/steps/persona/data.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/shared/src/features/onboarding/steps/persona/data.ts b/packages/shared/src/features/onboarding/steps/persona/data.ts
index 6d8fec21c4f..9a9d0bd860d 100644
--- a/packages/shared/src/features/onboarding/steps/persona/data.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/data.ts
@@ -189,7 +189,7 @@ export const QUESTIONS: PersonaQuestion[] = [
{ 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: 'You let AI write whole functions or features for you, not just autocomplete suggestions.', layer: 2 },
+ { text: 'You use agentic AI tools (Claude Code, Cursor Agent) that run on tasks autonomously, not just chat or autocomplete.', layer: 2 },
{ text: 'You\'ve shipped code that calls OpenAI, Anthropic, or another LLM API.', layer: 2 },
{ text: 'You\'ve spent a weekend customizing your editor or dotfiles.', layer: 2 },
{ text: 'You\'ve fine-tuned an ML model in the last six months.', layer: 2 },
From 8cb14735c3449d3f2d523536acba1a1068c1a73d Mon Sep 17 00:00:00 2001
From: idoshamun <16071328+idoshamun@users.noreply.github.com>
Date: Thu, 4 Jun 2026 06:47:55 +0000
Subject: [PATCH 09/15] feat(onboarding): collapse AI App Builder + Eng Leader,
add Tech Strategist and modifier checkbox screen
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Persona model overhaul based on playtest feedback. AI tooling kept
turning out to be a dimension layered on top of an underlying tech
persona rather than a persona itself โ everyone uses AI now, so it
does not separate developers any better than 'has a code editor'
would. Same goes for the Engineering Leader signal.
Changes:
Persona model (15 -> 14):
- DROP AI App Builder: it was really 'Full-Stack Web Dev + heavy
AI'. Those users now land in Full-Stack Web Dev with the AI Heavy
modifier set instead.
- DROP Engineering Leader as a primary persona: leading is a layer
on top of an engineering identity, not the identity itself. Now
surfaced via the Engineering Leader modifier instead.
- ADD Tech Strategist: covers product managers, designers, tech
execs and other tech-adjacent roles that don't write code as
their day-to-day job. Detected via a hard-lock question and the
Tech Strategist persona id.
Quiz changes (25 -> 19 questions):
- DROP Q3 'read more than code these days' (was Eng Leader signal)
- DROP Q11 'agentic AI' (dimension)
- DROP Q12 'shipped LLM API code' (dimension)
- DROP Q13 'dotfiles weekend' (orphaned)
- DROP Q14 'fine-tuned ML model' (dimension)
- DROP Q16 'web apps with AI built in' (AI App Builder discriminator)
- DROP Q22 'follow industry news' (Eng Leader signal)
- ADD Q3 new 'You don't write code as part of your day-to-day job.'
with lockPersonaId 'tech-strategist'.
Modifiers (new):
- New PersonaModifier type and MODIFIERS export with three entries:
ai-heavy, indie-hacker, engineering-leader. Each has an id,
label, emoji and description.
- Modifiers are chosen by the user on a single checkbox screen
after the persona is determined and before the final reveal. The
selection ships out via onTransition details under a new
modifiers field (string[]).
Hook + UI:
- usePersonaQuiz gains 'modifiers' phase, selectedModifierIds and
toggleModifier, confirmModifiers. The phase flow is now
playing -> tiebreak/triplebreak/picker -> modifiers -> reveal.
- FunnelPersonaQuiz renders the modifiers checkbox screen with the
Patchy mascot + three opt-in cards. Selected modifiers are
shown as small chips on the reveal screen so the user can see
what got applied.
- The demo page surfaces the modifiers list in the completion
card alongside persona / confidence / questions.
Matrix and prior regenerated:
- Drop AI App Builder and Engineering Leader rows from the
likelihood matrix.
- Drop the seven dropped question columns.
- Insert a hand-crafted Tech Strategist row (Q21 'boxes and arrows'
high since PMs/designers diagram a lot; everything else low).
- Insert a hand-crafted column for the new Tech Strategist lock
question (95% YES for Tech Strategist, <=8% for everyone else).
- Redistribute prior mass: AI App Builder -> Full-Stack, Engineering
Leader -> Software Architect, Tech Strategist seeded at ~2.5%.
- Renormalised so prior sums to 1.
---
.../onboarding/steps/FunnelPersonaQuiz.tsx | 103 +++++++++++++++-
.../features/onboarding/steps/persona/data.ts | 114 ++++++++++--------
.../steps/persona/usePersonaQuiz.ts | 101 ++++++++++++----
.../webapp/pages/onboarding-persona-demo.tsx | 8 ++
4 files changed, 250 insertions(+), 76 deletions(-)
diff --git a/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx b/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx
index 6d2b429bd11..7cde19e600a 100644
--- a/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx
+++ b/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx
@@ -1,5 +1,6 @@
import type { ReactElement } from 'react';
import React from 'react';
+import classNames from 'classnames';
import type { FunnelStepPersonaQuiz } from '../types/funnel';
import { FunnelStepTransitionType } from '../types/funnel';
import { withIsActiveGuard } from '../shared/withActiveGuard';
@@ -42,9 +43,7 @@ const PersonaCard = ({
>
@@ -75,6 +74,8 @@ function FunnelPersonaQuizComponent({
isThinking,
tiebreakPersonas,
triplebreakPersonas,
+ modifiers,
+ selectedModifierIds,
personas,
result,
isManual,
@@ -84,6 +85,8 @@ function FunnelPersonaQuizComponent({
chooseTiebreak,
pickManually,
selectPersona,
+ toggleModifier,
+ confirmModifiers,
} = usePersonaQuiz();
const handleComplete = () => {
@@ -91,6 +94,7 @@ function FunnelPersonaQuizComponent({
type: FunnelStepTransitionType.Complete,
details: {
persona: result?.persona.id,
+ modifiers: result?.modifiers ?? [],
confidence: isManual ? undefined : result?.confidence,
questions: questionsAnswered,
manual: isManual,
@@ -250,8 +254,84 @@ function FunnelPersonaQuizComponent({
);
}
+ 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 (
+ toggleModifier(modifier.id)}
+ className={classNames(
+ 'flex w-full items-center gap-4 rounded-16 border-2 p-4 text-left transition-colors',
+ checked
+ ? 'border-accent-cabbage-default bg-surface-float'
+ : 'border-border-subtlest-tertiary bg-surface-float hover:border-text-quaternary',
+ )}
+ >
+
+ {modifier.emoji}
+
+
+
+ {modifier.label}
+
+
+ {modifier.description}
+
+
+
+ {checked ? 'โ' : ''}
+
+
+ );
+ })}
+
+
+ {selectedModifierIds.length === 0 ? 'None of these โ continue' : 'Continue โ'}
+
+
+ );
+ }
+
if (phase === 'reveal' && result) {
- const { persona } = result;
+ const { persona, modifiers: selectedIds } = result;
+ const appliedModifiers = modifiers.filter((m) =>
+ selectedIds.includes(m.id),
+ );
return (
{persona.tagline}
+ {appliedModifiers.length > 0 && (
+
+ {appliedModifiers.map((modifier) => (
+
+ {modifier.emoji}
+
+ {modifier.label}
+
+
+ ))}
+
+ )}
void;
pickManually: () => void;
selectPersona: (personaId: string) => void;
+ toggleModifier: (modifierId: string) => void;
+ confirmModifiers: () => void;
restart: () => void;
}
@@ -72,16 +83,29 @@ export const usePersonaQuiz = (): PersonaQuizState => {
const [tiebreak, setTiebreak] = useState(null);
const [resultIndex, setResultIndex] = useState(null);
const [isManual, setIsManual] = useState(false);
+ const [selectedModifierIds, setSelectedModifierIds] = useState([]);
+ /** True once the user has confirmed (or skipped) the modifier screen. */
+ const [modifiersConfirmed, setModifiersConfirmed] = useState(false);
const askedRef = useRef>(new Set());
const excludedGroupsRef = useRef>(new Set());
const thinkingTimeout = useRef>();
- const reveal = useCallback((index: number, nextBelief: number[]) => {
- setResultIndex(index);
- setTiebreak(null);
+ const goToModifiers = useCallback(
+ (index: number, nextBelief: number[]) => {
+ setResultIndex(index);
+ setTiebreak(null);
+ setBelief(nextBelief);
+ setSelectedModifierIds([]);
+ setModifiersConfirmed(false);
+ setPhase('modifiers');
+ },
+ [],
+ );
+
+ /** Reveal the final persona (called after modifiers are confirmed). */
+ const reveal = useCallback(() => {
setPhase('reveal');
- setBelief(nextBelief);
}, []);
const finish = useCallback(
@@ -91,22 +115,22 @@ export const usePersonaQuiz = (): PersonaQuizState => {
const runnerUp = ranked[1];
const third = ranked[2];
- // 1. Confident top-1: reveal directly.
+ // 1. Confident top-1: skip to modifiers.
if (
top.belief >= tiebreakThreshold &&
top.belief - runnerUp.belief >= tiebreakMargin
) {
- reveal(top.index, nextBelief);
+ goToModifiers(top.index, nextBelief);
return;
}
// 2. Belief is too diffuse: fall back to the generalist.
if (top.belief < fallbackFloor) {
- reveal(FALLBACK_PERSONA_INDEX, nextBelief);
+ goToModifiers(FALLBACK_PERSONA_INDEX, nextBelief);
return;
}
- // 3. Belief is moderate but not decisive: offer a two-way pick.
+ // 3. Belief is moderate but not decisive: two-way pick.
if (top.belief >= triplebreakFloor && runnerUp) {
setTiebreak([top.index, runnerUp.index]);
setBelief(nextBelief);
@@ -114,7 +138,7 @@ export const usePersonaQuiz = (): PersonaQuizState => {
return;
}
- // 4. Belief is low but above fallback: offer a three-way pick.
+ // 4. Below triplebreak floor: three-way pick.
const candidates = [top.index, runnerUp?.index, third?.index].filter(
(index): index is number => typeof index === 'number',
);
@@ -126,9 +150,9 @@ export const usePersonaQuiz = (): PersonaQuizState => {
return;
}
- reveal(top.index, nextBelief);
+ goToModifiers(top.index, nextBelief);
},
- [reveal],
+ [goToModifiers],
);
const advance = useCallback(
@@ -177,6 +201,8 @@ export const usePersonaQuiz = (): PersonaQuizState => {
setTiebreak(null);
setIsThinking(false);
setIsManual(false);
+ setSelectedModifierIds([]);
+ setModifiersConfirmed(false);
setQuestionsShown(0);
setPhase('playing');
advance(fresh, 0);
@@ -193,9 +219,6 @@ export const usePersonaQuiz = (): PersonaQuizState => {
setBelief(nextBelief);
setIsThinking(true);
- // Once the user commits to a mutually-exclusive question (e.g. "main
- // language is JS/TS"), close out the entire exclusive group so the
- // engine stops asking contradictory follow-ups.
if (value === 1 && question.exclusiveGroup) {
excludedGroupsRef.current.add(question.exclusiveGroup);
}
@@ -203,14 +226,12 @@ export const usePersonaQuiz = (): PersonaQuizState => {
thinkingTimeout.current = setTimeout(() => {
setIsThinking(false);
- // Hard lock: a yes to a self-identification question reveals that
- // persona immediately, regardless of the current belief.
if (value === 1 && question.lockPersonaId) {
const lockIndex = PERSONAS.findIndex(
(persona) => persona.id === question.lockPersonaId,
);
if (lockIndex >= 0) {
- reveal(lockIndex, nextBelief);
+ goToModifiers(lockIndex, nextBelief);
return;
}
}
@@ -218,7 +239,14 @@ export const usePersonaQuiz = (): PersonaQuizState => {
advance(nextBelief, questionsShown);
}, THINKING_DURATION_MS);
},
- [advance, belief, currentQuestion, isThinking, questionsShown, reveal],
+ [
+ advance,
+ belief,
+ currentQuestion,
+ goToModifiers,
+ isThinking,
+ questionsShown,
+ ],
);
const chooseTiebreak = useCallback(
@@ -227,15 +255,17 @@ export const usePersonaQuiz = (): PersonaQuizState => {
if (index < 0) {
return;
}
- reveal(index, belief);
+ goToModifiers(index, belief);
},
- [belief, reveal],
+ [belief, goToModifiers],
);
const pickManually = useCallback(() => {
setResultIndex(null);
setTiebreak(null);
setIsManual(false);
+ setSelectedModifierIds([]);
+ setModifiersConfirmed(false);
setPhase('picker');
}, []);
@@ -246,11 +276,25 @@ export const usePersonaQuiz = (): PersonaQuizState => {
return;
}
setIsManual(true);
- reveal(index, belief);
+ goToModifiers(index, belief);
},
- [belief, reveal],
+ [belief, goToModifiers],
);
+ const toggleModifier = useCallback((modifierId: string) => {
+ setSelectedModifierIds((current) => {
+ if (current.includes(modifierId)) {
+ return current.filter((id) => id !== modifierId);
+ }
+ return [...current, modifierId];
+ });
+ }, []);
+
+ const confirmModifiers = useCallback(() => {
+ setModifiersConfirmed(true);
+ reveal();
+ }, [reveal]);
+
const restart = useCallback(() => {
if (thinkingTimeout.current) {
clearTimeout(thinkingTimeout.current);
@@ -263,6 +307,8 @@ export const usePersonaQuiz = (): PersonaQuizState => {
setTiebreak(null);
setResultIndex(null);
setIsManual(false);
+ setSelectedModifierIds([]);
+ setModifiersConfirmed(false);
askedRef.current = new Set();
excludedGroupsRef.current = new Set();
}, []);
@@ -288,8 +334,9 @@ export const usePersonaQuiz = (): PersonaQuizState => {
return {
persona: PERSONAS[resultIndex],
confidence: belief[resultIndex],
+ modifiers: modifiersConfirmed ? selectedModifierIds : [],
};
- }, [belief, resultIndex]);
+ }, [belief, modifiersConfirmed, resultIndex, selectedModifierIds]);
return {
phase,
@@ -301,6 +348,8 @@ export const usePersonaQuiz = (): PersonaQuizState => {
isThinking,
tiebreakPersonas,
triplebreakPersonas,
+ modifiers: MODIFIERS,
+ selectedModifierIds,
personas: PERSONAS,
result,
isManual,
@@ -310,6 +359,8 @@ export const usePersonaQuiz = (): PersonaQuizState => {
chooseTiebreak,
pickManually,
selectPersona,
+ toggleModifier,
+ confirmModifiers,
restart,
};
};
diff --git a/packages/webapp/pages/onboarding-persona-demo.tsx b/packages/webapp/pages/onboarding-persona-demo.tsx
index 4ca382176fe..d7ba4604bdf 100644
--- a/packages/webapp/pages/onboarding-persona-demo.tsx
+++ b/packages/webapp/pages/onboarding-persona-demo.tsx
@@ -31,6 +31,7 @@ const seo: NextSeoProps = {
type CompletionDetails = {
persona?: string;
+ modifiers?: string[];
confidence?: number;
questions: number;
};
@@ -85,6 +86,13 @@ function PersonaQuizDemo(): ReactElement {
: 'n/a'}
+ modifiers:{' '}
+
+ {completion.modifiers && completion.modifiers.length > 0
+ ? completion.modifiers.join(', ')
+ : 'none'}
+
+
questions: {completion.questions}
Date: Thu, 4 Jun 2026 07:38:24 +0000
Subject: [PATCH 10/15] feat(onboarding): recalibrate persona matrix from
engagement data
The v7 commit dropped two personas and seven questions but kept the
existing matrix rows for the surviving personas. The Tech Strategist
row and the new lock column were hand-crafted. That left the prior
and the dropped users redistribution as a guess rather than data.
This commit re-runs the full clustering pipeline against 93,345
active daily.dev users (90d engagement) using the v7 persona model
and the trimmed 19-question set. The thirteen engineer-persona rows
are recomputed from real cluster membership. Tech Strategist stays
hand-crafted since non-engineers don't appear in the engagement
clustering.
Key calibration outcomes from the simulator on the recalibrated
matrix:
Top-1 65% / Top-2 85% / Top-3 93% / median 6 questions / 58%
lock within 6 questions.
Per-persona top-3 (the relevant metric when triple-tie UX is
available): >95% for Generalist, Full-Stack, Frontend, Architect;
85-90% for Backend, Systems, DevOps, PHP, Mobile, .NET; 60-85%
for AI Specialist, Game, Security. Tech Strategist guaranteed
via its lock question (Q3) regardless of behaviour.
Two structural notes:
- Security Engineer cluster didn't surface as its own K-means
centroid in this run (cluster shapes shifted slightly versus
the v6 pull). Hand-crafted via topic-share filter (>=20%
security engagement) to recover ~3,400 users into the cluster.
- Engineering Leader cluster (high culture_career) is folded
into Software Architect now that Engineering Leader is a
modifier rather than a persona.
The likelihood matrix and prior in data.ts are the canonical
output of this run. Pipeline script in brain repo to be updated
in a follow-up so it produces this exact matrix on next refresh.
---
.../features/onboarding/steps/persona/data.ts | 64 +++++--------------
1 file changed, 17 insertions(+), 47 deletions(-)
diff --git a/packages/shared/src/features/onboarding/steps/persona/data.ts b/packages/shared/src/features/onboarding/steps/persona/data.ts
index 97c7433ebae..f8b4278f8a6 100644
--- a/packages/shared/src/features/onboarding/steps/persona/data.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/data.ts
@@ -10,34 +10,11 @@ export interface DeveloperPersona {
export interface PersonaQuestion {
text: string;
- /**
- * Funnel depth the question belongs to. Lower layers are broad
- * (frontend vs backend), higher layers are specific (which language,
- * AI usage, on-call experience). The engine unlocks deeper layers as
- * the belief narrows down.
- */
layer: number;
- /**
- * When set, a yes answer is treated as a hard self-identification:
- * the engine reveals this persona immediately, regardless of the
- * current belief. Used for niche-locking questions where the answer
- * IS the persona (e.g. 'Security is your primary job').
- */
lockPersonaId?: string;
- /**
- * When set, this question is mutually exclusive with every other
- * question that shares the same group label. Once any of them is
- * answered yes, the engine stops asking the rest.
- */
exclusiveGroup?: string;
}
-/**
- * A modifier is an orthogonal trait the user can opt into AFTER the
- * persona reveal. Modifiers do not change the primary persona but
- * boost feed content along that dimension (AI tooling content for
- * AI Heavy, founder/startup content for Indie Hacker, etc.).
- */
export interface PersonaModifier {
id: string;
label: string;
@@ -46,23 +23,15 @@ export interface PersonaModifier {
}
export interface PersonaEngineConfig {
- /** Confidence needed to stop early once minQuestions is reached. */
confidenceThreshold: number;
- /** Top belief required to reveal directly instead of offering a tiebreak. */
tiebreakThreshold: number;
- /** Minimum belief gap between the top two to reveal directly. */
tiebreakMargin: number;
- /** Below this top belief, offer a three-way pick instead of a two-way tiebreak. */
triplebreakFloor: number;
- /** Below this top belief, fall back to the generalist persona. */
fallbackFloor: number;
- /** Persona id used when belief is too diffuse to call confidently. */
fallbackPersonaId: string;
maxQuestions: number;
minQuestions: number;
- /** Top belief required to instantly reveal, overriding minQuestions. */
instantLockThreshold: number;
- /** Minimum margin between top two beliefs for the instant lock to fire. */
instantLockMargin: number;
}
@@ -229,30 +198,31 @@ export const MODIFIERS: PersonaModifier[] = [
/**
* Likelihood matrix: P[persona][question] = probability a member of that
- * persona answers "yes". Rows align with PERSONAS, columns with QUESTIONS.
+ * 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
- * since that persona isn't yet visible in behavioural clustering.
+ * (non-engineers do not appear in the engagement clustering).
*/
export const PERSONA_QUESTION_LIKELIHOOD: number[][] = [
- [0.189, 0.273, 0.03, 0.147, 0.414, 0.0, 0.028, 0.357, 0.157, 0.328, 0.003, 0.002, 0.001, 0.185, 0.245, 0.203, 0.034, 0.001, 0.003],
- [1.0, 0.0, 0.02, 1.0, 0.155, 0.0, 0.01, 1.0, 0.081, 0.234, 0.0, 0.008, 0.002, 0.083, 0.201, 0.115, 0.001, 0.002, 0.0],
- [1.0, 0.0, 0.02, 1.0, 0.033, 0.0, 0.004, 1.0, 0.041, 0.064, 0.0, 0.012, 0.003, 0.011, 0.046, 0.026, 0.84, 0.006, 0.006],
- [0.058, 0.06, 0.03, 0.052, 0.08, 0.0, 0.019, 0.088, 0.131, 0.055, 0.885, 0.007, 0.004, 0.041, 0.04, 0.063, 0.564, 0.002, 0.006],
- [0.09, 0.87, 0.02, 0.099, 0.084, 0.0, 0.015, 0.118, 0.09, 0.079, 0.001, 0.006, 0.002, 0.036, 0.999, 0.137, 0.157, 0.002, 0.001],
- [0.05, 0.365, 0.08, 0.062, 0.06, 0.0, 0.011, 0.109, 0.086, 0.102, 0.0, 0.005, 0.002, 0.066, 0.3, 0.909, 0.048, 0.001, 0.001],
- [0.099, 0.854, 0.02, 0.1, 0.948, 0.0, 0.028, 0.169, 0.127, 0.987, 0.002, 0.005, 0.001, 0.119, 0.158, 0.149, 0.065, 0.002, 0.002],
- [0.069, 0.908, 0.05, 0.07, 0.938, 0.0, 0.021, 0.151, 0.111, 0.171, 0.004, 0.004, 0.001, 0.943, 0.234, 0.191, 0.118, 0.001, 0.008],
- [0.989, 0.154, 0.02, 1.0, 0.119, 0.0, 0.006, 0.338, 0.062, 0.151, 0.001, 0.879, 0.001, 0.093, 0.193, 0.075, 0.136, 0.003, 0.004],
- [0.409, 0.465, 0.05, 0.507, 0.162, 0.0, 0.021, 0.507, 0.12, 0.09, 0.003, 0.009, 0.001, 0.045, 0.062, 0.032, 0.135, 0.008, 0.683],
- [0.097, 0.841, 0.02, 0.097, 0.169, 0.0, 0.006, 0.218, 0.045, 0.158, 0.0, 0.003, 0.825, 0.073, 0.207, 0.218, 0.141, 0.008, 0.004],
- [0.946, 0.002, 0.02, 0.169, 0.169, 0.0, 0.028, 0.259, 0.132, 0.209, 0.0, 0.006, 0.01, 0.033, 0.079, 0.05, 0.128, 0.763, 0.002],
- [0.914, 0.0, 0.02, 0.359, 0.119, 1.0, 0.005, 0.376, 0.072, 0.058, 0.016, 0.015, 0.005, 0.025, 0.096, 0.115, 0.142, 0.016, 0.004],
+ [0.192, 0.263, 0.0, 0.148, 0.411, 0.005, 0.029, 0.358, 0.16, 0.331, 0.003, 0.002, 0.001, 0.186, 0.244, 0.206, 0.03, 0.001, 0.0],
+ [0.956, 0.002, 0.0, 0.955, 0.111, 0.011, 0.008, 0.97, 0.082, 0.189, 0.011, 0.005, 0.001, 0.071, 0.151, 0.103, 0.004, 0.002, 0.0],
+ [1.0, 0.0, 0.0, 1.0, 0.035, 0.019, 0.005, 1.0, 0.044, 0.065, 0.0, 0.012, 0.002, 0.011, 0.045, 0.026, 0.826, 0.006, 0.0],
+ [0.062, 0.052, 0.0, 0.052, 0.08, 0.006, 0.019, 0.088, 0.131, 0.055, 0.891, 0.007, 0.004, 0.041, 0.04, 0.066, 0.568, 0.002, 0.0],
+ [0.095, 0.863, 0.0, 0.099, 0.083, 0.005, 0.015, 0.118, 0.091, 0.078, 0.001, 0.006, 0.002, 0.036, 0.999, 0.136, 0.157, 0.002, 0.0],
+ [0.061, 0.214, 0.0, 0.066, 0.066, 0.006, 0.033, 0.114, 0.152, 0.095, 0.016, 0.005, 0.002, 0.048, 0.213, 0.44, 0.043, 0.001, 0.0],
+ [0.103, 0.85, 0.0, 0.097, 0.95, 0.006, 0.026, 0.167, 0.126, 0.987, 0.002, 0.005, 0.002, 0.119, 0.159, 0.153, 0.066, 0.002, 0.0],
+ [0.073, 0.9, 0.0, 0.07, 0.936, 0.007, 0.02, 0.154, 0.111, 0.172, 0.004, 0.004, 0.001, 0.939, 0.234, 0.196, 0.118, 0.001, 0.0],
+ [0.987, 0.13, 0.0, 0.999, 0.12, 0.009, 0.006, 0.343, 0.066, 0.153, 0.001, 0.876, 0.001, 0.096, 0.197, 0.077, 0.137, 0.003, 0.0],
+ [0.344, 0.479, 0.0, 0.412, 0.273, 0.014, 0.014, 0.39, 0.1, 0.096, 0.027, 0.028, 0.007, 0.055, 0.077, 0.048, 0.164, 0.009, 0.394],
+ [0.1, 0.836, 0.0, 0.099, 0.171, 0.006, 0.005, 0.217, 0.049, 0.163, 0.0, 0.003, 0.824, 0.073, 0.205, 0.222, 0.143, 0.008, 0.0],
+ [0.944, 0.001, 0.0, 0.161, 0.166, 0.022, 0.03, 0.247, 0.143, 0.207, 0.001, 0.006, 0.01, 0.034, 0.079, 0.047, 0.136, 0.771, 0.0],
+ [0.986, 0.002, 0.0, 0.266, 0.094, 0.989, 0.008, 0.296, 0.077, 0.046, 0.002, 0.007, 0.004, 0.026, 0.101, 0.085, 0.131, 0.01, 0.0],
[0.2, 0.03, 0.95, 0.05, 0.02, 0.03, 0.05, 0.03, 0.05, 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.202, 0.2043, 0.0708, 0.0726, 0.07, 0.1267, 0.0528, 0.0411, 0.0398, 0.0244, 0.0231, 0.0172, 0.0309, 0.0244,
+ 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 = {
From e0757f5c0b1dde6508378a28a2219d06d84a5d61 Mon Sep 17 00:00:00 2001
From: idoshamun <16071328+idoshamun@users.noreply.github.com>
Date: Thu, 4 Jun 2026 08:31:46 +0000
Subject: [PATCH 11/15] feat(onboarding): group Q4/Q6/Q7 under primary-platform
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Q4 'main output is a web app in a browser', Q6 'you build apps for
iPhone or Android' and Q7 'your day involves Jupyter notebooks,
datasets, or training runs' all describe the same axis: the user's
primary build platform. A yes on one implies no on the others โ
your main output can be web OR mobile OR notebooks, not several.
Putting all three in a 'primary-platform' exclusiveGroup. Once any
one is answered yes, the engine stops asking the other two. Same
mechanism we use for main-language (Q8/Q9/Q10/Q12/Q13) and
primary-domain (Q1/Q2).
Q6 (mobile) is also a lock, so the lock already short-circuits any
follow-ups when it fires; the group label is the defensive belt for
the case where Q4 or Q7 is answered first.
---
.../shared/src/features/onboarding/steps/persona/data.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/shared/src/features/onboarding/steps/persona/data.ts b/packages/shared/src/features/onboarding/steps/persona/data.ts
index f8b4278f8a6..66ccc795aa4 100644
--- a/packages/shared/src/features/onboarding/steps/persona/data.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/data.ts
@@ -154,10 +154,10 @@ export const QUESTIONS: PersonaQuestion[] = [
{ text: 'You ship the things users see and click on.', layer: 0, exclusiveGroup: 'primary-domain' },
{ text: 'Your work is mostly backend or infrastructure, not frontend or mobile.', layer: 0, exclusiveGroup: 'primary-domain' },
{ 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 },
+ { 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' },
- { text: 'Your day involves Jupyter notebooks, datasets, or training runs.', 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' },
From c70cec5ff164526904552d99854895aae4dbab14 Mon Sep 17 00:00:00 2001
From: Ido Shamun <1993245+idoshamun@users.noreply.github.com>
Date: Thu, 4 Jun 2026 11:46:19 +0300
Subject: [PATCH 12/15] feat(onboarding): persona quiz micro-interactions,
modifiers after persona lock, answer debug log
---
.../steps/FunnelPersonaQuiz.module.css | 170 +++++++++++
.../onboarding/steps/FunnelPersonaQuiz.tsx | 281 ++++++++++++------
.../steps/persona/usePersonaQuiz.ts | 103 ++++---
.../src/features/onboarding/types/funnel.ts | 1 +
4 files changed, 422 insertions(+), 133 deletions(-)
create mode 100644 packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.module.css
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
index 7cde19e600a..f87b3f6229e 100644
--- a/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx
+++ b/packages/shared/src/features/onboarding/steps/FunnelPersonaQuiz.tsx
@@ -1,5 +1,5 @@
-import type { ReactElement } from 'react';
-import React from 'react';
+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';
@@ -16,12 +16,46 @@ import {
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 {
@@ -70,6 +104,7 @@ function FunnelPersonaQuizComponent({
}: FunnelStepPersonaQuiz): ReactElement {
const {
phase,
+ questionNumber,
questionText,
isThinking,
tiebreakPersonas,
@@ -85,10 +120,28 @@ function FunnelPersonaQuizComponent({
chooseTiebreak,
pickManually,
selectPersona,
+ confirmPersona,
toggleModifier,
- confirmModifiers,
} = 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,
@@ -104,10 +157,13 @@ function FunnelPersonaQuizComponent({
if (phase === 'intro') {
return (
-
+
{MASCOT_EMOJI}
@@ -151,7 +207,10 @@ function FunnelPersonaQuizComponent({
if (phase === 'picker') {
return (
-
+
Who are you, really?
@@ -195,8 +254,16 @@ function FunnelPersonaQuizComponent({
if (phase === 'tiebreak') {
return (
-
-
{MASCOT_EMOJI}
+
+
+ {MASCOT_EMOJI}
+
I'm torn between these two.
@@ -221,8 +288,16 @@ function FunnelPersonaQuizComponent({
if (phase === 'triplebreak') {
return (
-
-
{MASCOT_EMOJI}
+
+
+ {MASCOT_EMOJI}
+
You're a tough one. Could be any of these three.
@@ -256,8 +331,16 @@ function FunnelPersonaQuizComponent({
if (phase === 'modifiers' && result) {
return (
-
-
{MASCOT_EMOJI}
+
+
+ {MASCOT_EMOJI}
+
One more thing.
@@ -266,8 +349,8 @@ function FunnelPersonaQuizComponent({
color={TypographyColor.Secondary}
className="max-w-md"
>
- Tick any of these that describe you. They tune your feed beyond
- your persona.
+ Tick any of these that describe you. They tune your feed beyond your
+ persona.
{modifiers.map((modifier) => {
@@ -318,35 +401,50 @@ function FunnelPersonaQuizComponent({
- {selectedModifierIds.length === 0 ? 'None of these โ continue' : 'Continue โ'}
+ {selectedModifierIds.length === 0
+ ? 'None of these โ continue'
+ : 'Continue โ'}
);
}
if (phase === 'reveal' && result) {
- const { persona, modifiers: selectedIds } = result;
- const appliedModifiers = modifiers.filter((m) =>
- selectedIds.includes(m.id),
- );
+ const { persona } = result;
return (
-
-
- {persona.emoji}
-
+
+
+
+
+ {persona.emoji}
+
+
{persona.name}
@@ -354,30 +452,20 @@ function FunnelPersonaQuizComponent({
{persona.tagline}
- {appliedModifiers.length > 0 && (
-
- {appliedModifiers.map((modifier) => (
-
- {modifier.emoji}
-
- {modifier.label}
-
-
- ))}
-
- )}
-
+
{cta || "Yes, that's me!"}
@@ -396,14 +484,27 @@ function FunnelPersonaQuizComponent({
}
return (
-
+
{MASCOT_EMOJI}
-
+
{questionText}
-
-
answer(1)}
+
+
- Yes
-
- answer(0.5)}
- >
- Not sure
-
- answer(0)}
- >
- No
-
+ handleAnswer(1)}
+ >
+ Yes
+
+ handleAnswer(0.5)}
+ >
+ Not sure
+
+ handleAnswer(0)}
+ >
+ No
+
+
+ {isThinking && (
+
+
+
+ )}
diff --git a/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts b/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
index 937c27ce965..42123300206 100644
--- a/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
@@ -8,12 +8,7 @@ import {
updateBelief,
} from './engine';
import type { DeveloperPersona, PersonaModifier } from './data';
-import {
- MODIFIERS,
- PERSONAS,
- PERSONA_ENGINE_CONFIG,
- QUESTIONS,
-} from './data';
+import { MODIFIERS, PERSONAS, PERSONA_ENGINE_CONFIG, QUESTIONS } from './data';
export type PersonaQuizPhase =
| 'intro'
@@ -27,6 +22,12 @@ export type PersonaQuizPhase =
/** 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,
@@ -69,8 +70,8 @@ export interface PersonaQuizState {
chooseTiebreak: (personaId: string) => void;
pickManually: () => void;
selectPersona: (personaId: string) => void;
+ confirmPersona: () => void;
toggleModifier: (modifierId: string) => void;
- confirmModifiers: () => void;
restart: () => void;
}
@@ -84,30 +85,38 @@ export const usePersonaQuiz = (): PersonaQuizState => {
const [resultIndex, setResultIndex] = useState(null);
const [isManual, setIsManual] = useState(false);
const [selectedModifierIds, setSelectedModifierIds] = useState([]);
- /** True once the user has confirmed (or skipped) the modifier screen. */
- const [modifiersConfirmed, setModifiersConfirmed] = useState(false);
const askedRef = useRef>(new Set());
const excludedGroupsRef = useRef>(new Set());
+ const answerLogRef = useRef<
+ Array<{ id: string; text: string; answer: string }>
+ >([]);
const thinkingTimeout = useRef>();
- const goToModifiers = useCallback(
- (index: number, nextBelief: number[]) => {
- setResultIndex(index);
- setTiebreak(null);
- setBelief(nextBelief);
- setSelectedModifierIds([]);
- setModifiersConfirmed(false);
- setPhase('modifiers');
- },
- [],
- );
-
- /** Reveal the final persona (called after modifiers are confirmed). */
- const reveal = useCallback(() => {
+ // 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);
@@ -120,13 +129,13 @@ export const usePersonaQuiz = (): PersonaQuizState => {
top.belief >= tiebreakThreshold &&
top.belief - runnerUp.belief >= tiebreakMargin
) {
- goToModifiers(top.index, nextBelief);
+ revealGuess(top.index, nextBelief);
return;
}
// 2. Belief is too diffuse: fall back to the generalist.
if (top.belief < fallbackFloor) {
- goToModifiers(FALLBACK_PERSONA_INDEX, nextBelief);
+ revealGuess(FALLBACK_PERSONA_INDEX, nextBelief);
return;
}
@@ -150,9 +159,9 @@ export const usePersonaQuiz = (): PersonaQuizState => {
return;
}
- goToModifiers(top.index, nextBelief);
+ revealGuess(top.index, nextBelief);
},
- [goToModifiers],
+ [revealGuess],
);
const advance = useCallback(
@@ -195,6 +204,7 @@ export const usePersonaQuiz = (): PersonaQuizState => {
const start = useCallback(() => {
askedRef.current = new Set();
excludedGroupsRef.current = new Set();
+ answerLogRef.current = [];
const fresh = initialBelief();
setBelief(fresh);
setResultIndex(null);
@@ -202,7 +212,6 @@ export const usePersonaQuiz = (): PersonaQuizState => {
setIsThinking(false);
setIsManual(false);
setSelectedModifierIds([]);
- setModifiersConfirmed(false);
setQuestionsShown(0);
setPhase('playing');
advance(fresh, 0);
@@ -219,6 +228,15 @@ export const usePersonaQuiz = (): PersonaQuizState => {
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);
}
@@ -231,7 +249,7 @@ export const usePersonaQuiz = (): PersonaQuizState => {
(persona) => persona.id === question.lockPersonaId,
);
if (lockIndex >= 0) {
- goToModifiers(lockIndex, nextBelief);
+ revealGuess(lockIndex, nextBelief);
return;
}
}
@@ -239,14 +257,7 @@ export const usePersonaQuiz = (): PersonaQuizState => {
advance(nextBelief, questionsShown);
}, THINKING_DURATION_MS);
},
- [
- advance,
- belief,
- currentQuestion,
- goToModifiers,
- isThinking,
- questionsShown,
- ],
+ [advance, belief, currentQuestion, revealGuess, isThinking, questionsShown],
);
const chooseTiebreak = useCallback(
@@ -255,9 +266,9 @@ export const usePersonaQuiz = (): PersonaQuizState => {
if (index < 0) {
return;
}
- goToModifiers(index, belief);
+ revealGuess(index, belief);
},
- [belief, goToModifiers],
+ [belief, revealGuess],
);
const pickManually = useCallback(() => {
@@ -265,7 +276,6 @@ export const usePersonaQuiz = (): PersonaQuizState => {
setTiebreak(null);
setIsManual(false);
setSelectedModifierIds([]);
- setModifiersConfirmed(false);
setPhase('picker');
}, []);
@@ -290,11 +300,6 @@ export const usePersonaQuiz = (): PersonaQuizState => {
});
}, []);
- const confirmModifiers = useCallback(() => {
- setModifiersConfirmed(true);
- reveal();
- }, [reveal]);
-
const restart = useCallback(() => {
if (thinkingTimeout.current) {
clearTimeout(thinkingTimeout.current);
@@ -308,9 +313,9 @@ export const usePersonaQuiz = (): PersonaQuizState => {
setResultIndex(null);
setIsManual(false);
setSelectedModifierIds([]);
- setModifiersConfirmed(false);
askedRef.current = new Set();
excludedGroupsRef.current = new Set();
+ answerLogRef.current = [];
}, []);
const tiebreakPersonas = useMemo(() => {
@@ -334,9 +339,9 @@ export const usePersonaQuiz = (): PersonaQuizState => {
return {
persona: PERSONAS[resultIndex],
confidence: belief[resultIndex],
- modifiers: modifiersConfirmed ? selectedModifierIds : [],
+ modifiers: selectedModifierIds,
};
- }, [belief, modifiersConfirmed, resultIndex, selectedModifierIds]);
+ }, [belief, resultIndex, selectedModifierIds]);
return {
phase,
@@ -359,8 +364,8 @@ export const usePersonaQuiz = (): PersonaQuizState => {
chooseTiebreak,
pickManually,
selectPersona,
+ confirmPersona,
toggleModifier,
- confirmModifiers,
restart,
};
};
diff --git a/packages/shared/src/features/onboarding/types/funnel.ts b/packages/shared/src/features/onboarding/types/funnel.ts
index b61a1e295d5..83a64dbbc20 100644
--- a/packages/shared/src/features/onboarding/types/funnel.ts
+++ b/packages/shared/src/features/onboarding/types/funnel.ts
@@ -390,6 +390,7 @@ export interface FunnelStepPersonaQuiz
confidence?: number;
questions: number;
manual: boolean;
+ modifiers: string[];
}>;
}
From c2a6382d6eae4edf0296abcd37988b4f2ae838a2 Mon Sep 17 00:00:00 2001
From: Ido Shamun <1993245+idoshamun@users.noreply.github.com>
Date: Thu, 4 Jun 2026 12:23:14 +0300
Subject: [PATCH 13/15] chore(onboarding): fix prettier formatting in persona
data/engine
---
.../features/onboarding/steps/persona/data.ts | 222 +++++++++++++-----
.../onboarding/steps/persona/engine.ts | 5 +-
2 files changed, 164 insertions(+), 63 deletions(-)
diff --git a/packages/shared/src/features/onboarding/steps/persona/data.ts b/packages/shared/src/features/onboarding/steps/persona/data.ts
index 66ccc795aa4..8d0fcc4a967 100644
--- a/packages/shared/src/features/onboarding/steps/persona/data.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/data.ts
@@ -49,96 +49,84 @@ export const PERSONAS: DeveloperPersona[] = [
name: 'Full-Stack Web Developer',
emoji: 'โ๏ธ',
color: '#06b6d4',
- tagline:
- "React, TypeScript, Node, the works. You ship product.",
+ 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.",
+ 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.",
+ 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.",
+ 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.",
+ 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.",
+ 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.",
+ 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.",
+ 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.",
+ 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.",
+ 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.",
+ 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.",
+ tagline: 'iOS, Android, Flutter. The app store is your stage.',
},
{
id: 'tech-strategist',
@@ -146,30 +134,98 @@ export const PERSONAS: DeveloperPersona[] = [
emoji: '๐ผ',
color: '#64748b',
tagline:
- "Product, design, strategy. You shape what gets built without writing the code.",
+ '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' },
- { text: 'Your work is mostly backend or infrastructure, not frontend or mobile.', layer: 0, exclusiveGroup: 'primary-domain' },
- { 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: '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: 'You\'ve been the one paged at 3am when production went down.', layer: 3 },
+ {
+ text: 'You ship the things users see and click on.',
+ layer: 0,
+ exclusiveGroup: 'primary-domain',
+ },
+ {
+ text: 'Your work is mostly backend or infrastructure, not frontend or mobile.',
+ layer: 0,
+ exclusiveGroup: 'primary-domain',
+ },
+ {
+ 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: '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: "You've been the one paged at 3am when production went down.",
+ layer: 3,
+ },
{ 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' },
+ {
+ 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[] = [
@@ -178,21 +234,20 @@ export const MODIFIERS: PersonaModifier[] = [
label: 'AI Heavy',
emoji: '๐ค',
description:
- "You use AI tools (Cursor, Claude, agents) for meaningful chunks of your work.",
+ '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.",
+ 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.",
+ 'You lead engineers or set technical direction more than you write code.',
},
];
@@ -204,25 +259,68 @@ export const MODIFIERS: PersonaModifier[] = [
* (non-engineers do not appear in the engagement clustering).
*/
export const PERSONA_QUESTION_LIKELIHOOD: number[][] = [
- [0.192, 0.263, 0.0, 0.148, 0.411, 0.005, 0.029, 0.358, 0.16, 0.331, 0.003, 0.002, 0.001, 0.186, 0.244, 0.206, 0.03, 0.001, 0.0],
- [0.956, 0.002, 0.0, 0.955, 0.111, 0.011, 0.008, 0.97, 0.082, 0.189, 0.011, 0.005, 0.001, 0.071, 0.151, 0.103, 0.004, 0.002, 0.0],
- [1.0, 0.0, 0.0, 1.0, 0.035, 0.019, 0.005, 1.0, 0.044, 0.065, 0.0, 0.012, 0.002, 0.011, 0.045, 0.026, 0.826, 0.006, 0.0],
- [0.062, 0.052, 0.0, 0.052, 0.08, 0.006, 0.019, 0.088, 0.131, 0.055, 0.891, 0.007, 0.004, 0.041, 0.04, 0.066, 0.568, 0.002, 0.0],
- [0.095, 0.863, 0.0, 0.099, 0.083, 0.005, 0.015, 0.118, 0.091, 0.078, 0.001, 0.006, 0.002, 0.036, 0.999, 0.136, 0.157, 0.002, 0.0],
- [0.061, 0.214, 0.0, 0.066, 0.066, 0.006, 0.033, 0.114, 0.152, 0.095, 0.016, 0.005, 0.002, 0.048, 0.213, 0.44, 0.043, 0.001, 0.0],
- [0.103, 0.85, 0.0, 0.097, 0.95, 0.006, 0.026, 0.167, 0.126, 0.987, 0.002, 0.005, 0.002, 0.119, 0.159, 0.153, 0.066, 0.002, 0.0],
- [0.073, 0.9, 0.0, 0.07, 0.936, 0.007, 0.02, 0.154, 0.111, 0.172, 0.004, 0.004, 0.001, 0.939, 0.234, 0.196, 0.118, 0.001, 0.0],
- [0.987, 0.13, 0.0, 0.999, 0.12, 0.009, 0.006, 0.343, 0.066, 0.153, 0.001, 0.876, 0.001, 0.096, 0.197, 0.077, 0.137, 0.003, 0.0],
- [0.344, 0.479, 0.0, 0.412, 0.273, 0.014, 0.014, 0.39, 0.1, 0.096, 0.027, 0.028, 0.007, 0.055, 0.077, 0.048, 0.164, 0.009, 0.394],
- [0.1, 0.836, 0.0, 0.099, 0.171, 0.006, 0.005, 0.217, 0.049, 0.163, 0.0, 0.003, 0.824, 0.073, 0.205, 0.222, 0.143, 0.008, 0.0],
- [0.944, 0.001, 0.0, 0.161, 0.166, 0.022, 0.03, 0.247, 0.143, 0.207, 0.001, 0.006, 0.01, 0.034, 0.079, 0.047, 0.136, 0.771, 0.0],
- [0.986, 0.002, 0.0, 0.266, 0.094, 0.989, 0.008, 0.296, 0.077, 0.046, 0.002, 0.007, 0.004, 0.026, 0.101, 0.085, 0.131, 0.01, 0.0],
- [0.2, 0.03, 0.95, 0.05, 0.02, 0.03, 0.05, 0.03, 0.05, 0.01, 0.03, 0.01, 0.01, 0.02, 0.05, 0.4, 0.25, 0.02, 0.03],
+ [
+ 0.192, 0.263, 0.0, 0.148, 0.411, 0.005, 0.029, 0.358, 0.16, 0.331, 0.003,
+ 0.002, 0.001, 0.186, 0.244, 0.206, 0.03, 0.001, 0.0,
+ ],
+ [
+ 0.956, 0.002, 0.0, 0.955, 0.111, 0.011, 0.008, 0.97, 0.082, 0.189, 0.011,
+ 0.005, 0.001, 0.071, 0.151, 0.103, 0.004, 0.002, 0.0,
+ ],
+ [
+ 1.0, 0.0, 0.0, 1.0, 0.035, 0.019, 0.005, 1.0, 0.044, 0.065, 0.0, 0.012,
+ 0.002, 0.011, 0.045, 0.026, 0.826, 0.006, 0.0,
+ ],
+ [
+ 0.062, 0.052, 0.0, 0.052, 0.08, 0.006, 0.019, 0.088, 0.131, 0.055, 0.891,
+ 0.007, 0.004, 0.041, 0.04, 0.066, 0.568, 0.002, 0.0,
+ ],
+ [
+ 0.095, 0.863, 0.0, 0.099, 0.083, 0.005, 0.015, 0.118, 0.091, 0.078, 0.001,
+ 0.006, 0.002, 0.036, 0.999, 0.136, 0.157, 0.002, 0.0,
+ ],
+ [
+ 0.061, 0.214, 0.0, 0.066, 0.066, 0.006, 0.033, 0.114, 0.152, 0.095, 0.016,
+ 0.005, 0.002, 0.048, 0.213, 0.44, 0.043, 0.001, 0.0,
+ ],
+ [
+ 0.103, 0.85, 0.0, 0.097, 0.95, 0.006, 0.026, 0.167, 0.126, 0.987, 0.002,
+ 0.005, 0.002, 0.119, 0.159, 0.153, 0.066, 0.002, 0.0,
+ ],
+ [
+ 0.073, 0.9, 0.0, 0.07, 0.936, 0.007, 0.02, 0.154, 0.111, 0.172, 0.004,
+ 0.004, 0.001, 0.939, 0.234, 0.196, 0.118, 0.001, 0.0,
+ ],
+ [
+ 0.987, 0.13, 0.0, 0.999, 0.12, 0.009, 0.006, 0.343, 0.066, 0.153, 0.001,
+ 0.876, 0.001, 0.096, 0.197, 0.077, 0.137, 0.003, 0.0,
+ ],
+ [
+ 0.344, 0.479, 0.0, 0.412, 0.273, 0.014, 0.014, 0.39, 0.1, 0.096, 0.027,
+ 0.028, 0.007, 0.055, 0.077, 0.048, 0.164, 0.009, 0.394,
+ ],
+ [
+ 0.1, 0.836, 0.0, 0.099, 0.171, 0.006, 0.005, 0.217, 0.049, 0.163, 0.0,
+ 0.003, 0.824, 0.073, 0.205, 0.222, 0.143, 0.008, 0.0,
+ ],
+ [
+ 0.944, 0.001, 0.0, 0.161, 0.166, 0.022, 0.03, 0.247, 0.143, 0.207, 0.001,
+ 0.006, 0.01, 0.034, 0.079, 0.047, 0.136, 0.771, 0.0,
+ ],
+ [
+ 0.986, 0.002, 0.0, 0.266, 0.094, 0.989, 0.008, 0.296, 0.077, 0.046, 0.002,
+ 0.007, 0.004, 0.026, 0.101, 0.085, 0.131, 0.01, 0.0,
+ ],
+ [
+ 0.2, 0.03, 0.95, 0.05, 0.02, 0.03, 0.05, 0.03, 0.05, 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,
+ 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 = {
diff --git a/packages/shared/src/features/onboarding/steps/persona/engine.ts b/packages/shared/src/features/onboarding/steps/persona/engine.ts
index 756316b1471..35af2452e7a 100644
--- a/packages/shared/src/features/onboarding/steps/persona/engine.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/engine.ts
@@ -103,7 +103,10 @@ export const pickNextQuestion = (
if (asked.has(q) || !layers.has(question.layer)) {
return best;
}
- if (question.exclusiveGroup && excludedGroups.has(question.exclusiveGroup)) {
+ if (
+ question.exclusiveGroup &&
+ excludedGroups.has(question.exclusiveGroup)
+ ) {
return best;
}
const gain = informationGain(belief, q);
From 0e241ceb1730443d7a179c406cd9a8172912e4f4 Mon Sep 17 00:00:00 2001
From: idoshamun <16071328+idoshamun@users.noreply.github.com>
Date: Thu, 4 Jun 2026 09:29:45 +0000
Subject: [PATCH 14/15] feat(onboarding): cross-group implications + tighten
DevOps lock
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two fixes from a playtest where the user answered backend on Q2 and
then was asked Q4 ('main output is a web app') anyway, and ended up
classified as DevOps Engineer despite a clear senior-backend
answer pattern.
1. Cross-group implications
The exclusiveGroup mechanism only closes questions within the same
group. But Q1 ('you ship UI') and Q2 ('you're backend, not frontend
or mobile') are not just opposites of each other; they also imply
answers across the primary-platform group. A yes on Q2 rules out Q4
(web app) and Q6 (mobile app), but the engine had no way to express
that, so it asked Q4 later anyway when info gain was middling.
Added two optional fields to PersonaQuestion:
closesOnYes?: string[] // groups to close when this answer is yes
closesOnNo?: string[] // groups to close when this answer is no
Configured:
Q1 closesOnNo: ['primary-platform'] โ not UI => no UI platform
Q2 closesOnYes: ['primary-platform'] โ backend => no UI platform
usePersonaQuiz honors both when applying an answer.
2. DevOps lock too broad
Q14 was 'You've been the one paged at 3am when production went
down'. Every senior backend dev with on-call rotation answers yes,
which is why the playtest user (backend with Go/Rust and SQL and
diagrams) landed on DevOps Engineer instead of Backend Developer:
the DevOps row has 94 percent yes on Q14 vs 4 percent for Backend,
so yes on Q14 swamps the rest.
Reworded to identify DevOps as the role, not the duty:
'DevOps, SRE, or platform engineering is your job, not just a
side responsibility.'
A backend dev who is on rotation will answer no; a real DevOps,
SRE, or platform engineer will answer yes.
---
.../features/onboarding/steps/persona/data.ts | 17 ++++++++++++++++-
.../onboarding/steps/persona/usePersonaQuiz.ts | 13 +++++++++++++
2 files changed, 29 insertions(+), 1 deletion(-)
diff --git a/packages/shared/src/features/onboarding/steps/persona/data.ts b/packages/shared/src/features/onboarding/steps/persona/data.ts
index 8d0fcc4a967..70b009a5e99 100644
--- a/packages/shared/src/features/onboarding/steps/persona/data.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/data.ts
@@ -13,6 +13,19 @@ export interface PersonaQuestion {
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 {
@@ -143,11 +156,13 @@ 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.",
@@ -204,7 +219,7 @@ export const QUESTIONS: PersonaQuestion[] = [
exclusiveGroup: 'main-language',
},
{
- text: "You've been the one paged at 3am when production went down.",
+ text: 'DevOps, SRE, or platform engineering is your job, not just a side responsibility.',
layer: 3,
},
{ text: 'You write more SQL than CSS.', layer: 3 },
diff --git a/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts b/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
index 42123300206..fa8d57f1f97 100644
--- a/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/usePersonaQuiz.ts
@@ -240,6 +240,19 @@ export const usePersonaQuiz = (): PersonaQuizState => {
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);
From 3e8144da94122b15fb1daa63e7f9e943a64f92d3 Mon Sep 17 00:00:00 2001
From: idoshamun <16071328+idoshamun@users.noreply.github.com>
Date: Thu, 4 Jun 2026 10:30:39 +0000
Subject: [PATCH 15/15] feat(onboarding): finish open exclusive groups + DevOps
lock + Java question
Three fixes for a playtest where the user answered backend (Q2 yes),
got asked three of the five language questions (JS/TS, Go/Rust, .NET),
none of which was their language, then ended up classified as Backend
Developer without ever being asked about Python, PHP, or Java.
1. Active-group preference in pickNextQuestion
Until now the engine picked questions purely by expected information
gain. Inside the main-language group, once Q1 = no (not UI) was
answered, PHP belief collapsed to near zero (because PHP devs in the
data overwhelmingly answer Q1 = yes). PHP's expected info gain
followed, even though its conditional info gain on a yes is huge
(it's a lock). The engine reasonably decided PHP couldn't be the
answer and moved on, never asking the question.
The new rule: once any question in an open exclusiveGroup has been
asked, prefer the remaining group members before moving on. The
language group is treated as a single decision the user gets to
finish, not a single question the engine can drop after one no.
Same applies to primary-platform and primary-domain.
2. DevOps question is now a hard lock
'DevOps, SRE, or platform engineering is your job, not just a side
responsibility' is a self-identification question. Yes on it is
direct evidence the user IS DevOps, the same shape as Q12 (PHP),
Q13 (.NET), Q18 (game), Q19 (security). Added lockPersonaId:
'devops-engineer' so a yes locks the persona instead of relying on
the Bayesian update.
3. Added Java / Kotlin question
Java / Kotlin / JVM doesn't have its own persona (the JVM enterprise
cluster is too small to surface at K=16), so we never asked about
it. Users coming from Java land on Backend Developer or Software
Architect by inference. Added a new entry in the main-language
group ('Your main language is Java or Kotlin.') with no specific
lock; yes closes the language group and feeds the Bayesian
classifier with the right signal. Hand-crafted column in the
likelihood matrix: high yes rate for Backend Developer (35%),
Software Architect (30%) and Mobile Developer (30%, for Android
Kotlin), low everywhere else.
Bumped maxQuestions from 10 to 12 to make room for the longer
language pass. Median session length should still sit around 6-7
once a lock fires; only sessions that say no to every lock will
approach the new cap.
---
.../features/onboarding/steps/persona/data.ts | 79 ++++++-------------
.../onboarding/steps/persona/engine.ts | 68 +++++++++++-----
2 files changed, 71 insertions(+), 76 deletions(-)
diff --git a/packages/shared/src/features/onboarding/steps/persona/data.ts b/packages/shared/src/features/onboarding/steps/persona/data.ts
index 70b009a5e99..d38cf55968d 100644
--- a/packages/shared/src/features/onboarding/steps/persona/data.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/data.ts
@@ -201,6 +201,11 @@ export const QUESTIONS: PersonaQuestion[] = [
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,
@@ -221,6 +226,7 @@ export const QUESTIONS: PersonaQuestion[] = [
{
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 },
{
@@ -274,62 +280,21 @@ export const MODIFIERS: PersonaModifier[] = [
* (non-engineers do not appear in the engagement clustering).
*/
export const PERSONA_QUESTION_LIKELIHOOD: number[][] = [
- [
- 0.192, 0.263, 0.0, 0.148, 0.411, 0.005, 0.029, 0.358, 0.16, 0.331, 0.003,
- 0.002, 0.001, 0.186, 0.244, 0.206, 0.03, 0.001, 0.0,
- ],
- [
- 0.956, 0.002, 0.0, 0.955, 0.111, 0.011, 0.008, 0.97, 0.082, 0.189, 0.011,
- 0.005, 0.001, 0.071, 0.151, 0.103, 0.004, 0.002, 0.0,
- ],
- [
- 1.0, 0.0, 0.0, 1.0, 0.035, 0.019, 0.005, 1.0, 0.044, 0.065, 0.0, 0.012,
- 0.002, 0.011, 0.045, 0.026, 0.826, 0.006, 0.0,
- ],
- [
- 0.062, 0.052, 0.0, 0.052, 0.08, 0.006, 0.019, 0.088, 0.131, 0.055, 0.891,
- 0.007, 0.004, 0.041, 0.04, 0.066, 0.568, 0.002, 0.0,
- ],
- [
- 0.095, 0.863, 0.0, 0.099, 0.083, 0.005, 0.015, 0.118, 0.091, 0.078, 0.001,
- 0.006, 0.002, 0.036, 0.999, 0.136, 0.157, 0.002, 0.0,
- ],
- [
- 0.061, 0.214, 0.0, 0.066, 0.066, 0.006, 0.033, 0.114, 0.152, 0.095, 0.016,
- 0.005, 0.002, 0.048, 0.213, 0.44, 0.043, 0.001, 0.0,
- ],
- [
- 0.103, 0.85, 0.0, 0.097, 0.95, 0.006, 0.026, 0.167, 0.126, 0.987, 0.002,
- 0.005, 0.002, 0.119, 0.159, 0.153, 0.066, 0.002, 0.0,
- ],
- [
- 0.073, 0.9, 0.0, 0.07, 0.936, 0.007, 0.02, 0.154, 0.111, 0.172, 0.004,
- 0.004, 0.001, 0.939, 0.234, 0.196, 0.118, 0.001, 0.0,
- ],
- [
- 0.987, 0.13, 0.0, 0.999, 0.12, 0.009, 0.006, 0.343, 0.066, 0.153, 0.001,
- 0.876, 0.001, 0.096, 0.197, 0.077, 0.137, 0.003, 0.0,
- ],
- [
- 0.344, 0.479, 0.0, 0.412, 0.273, 0.014, 0.014, 0.39, 0.1, 0.096, 0.027,
- 0.028, 0.007, 0.055, 0.077, 0.048, 0.164, 0.009, 0.394,
- ],
- [
- 0.1, 0.836, 0.0, 0.099, 0.171, 0.006, 0.005, 0.217, 0.049, 0.163, 0.0,
- 0.003, 0.824, 0.073, 0.205, 0.222, 0.143, 0.008, 0.0,
- ],
- [
- 0.944, 0.001, 0.0, 0.161, 0.166, 0.022, 0.03, 0.247, 0.143, 0.207, 0.001,
- 0.006, 0.01, 0.034, 0.079, 0.047, 0.136, 0.771, 0.0,
- ],
- [
- 0.986, 0.002, 0.0, 0.266, 0.094, 0.989, 0.008, 0.296, 0.077, 0.046, 0.002,
- 0.007, 0.004, 0.026, 0.101, 0.085, 0.131, 0.01, 0.0,
- ],
- [
- 0.2, 0.03, 0.95, 0.05, 0.02, 0.03, 0.05, 0.03, 0.05, 0.01, 0.03, 0.01, 0.01,
- 0.02, 0.05, 0.4, 0.25, 0.02, 0.03,
- ],
+ [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). */
@@ -345,7 +310,7 @@ export const PERSONA_ENGINE_CONFIG: PersonaEngineConfig = {
triplebreakFloor: 0.3,
fallbackFloor: 0.12,
fallbackPersonaId: 'generalist-developer',
- maxQuestions: 10,
+ 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
index 35af2452e7a..efeea2db938 100644
--- a/packages/shared/src/features/onboarding/steps/persona/engine.ts
+++ b/packages/shared/src/features/onboarding/steps/persona/engine.ts
@@ -86,9 +86,16 @@ const allowedLayers = (questionsShown: number): Set => {
/**
* Returns the next best question index, or -1 when none remain.
- * Questions whose exclusiveGroup has been answered yes already are skipped:
- * once the user picks their main language, asking about the other languages
- * is contradictory and wastes a slot.
+ *
+ * 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[],
@@ -98,22 +105,45 @@ export const pickNextQuestion = (
): number => {
const layers = allowedLayers(questionsShown);
- return QUESTIONS.reduce(
- (best, question, q) => {
- if (asked.has(q) || !layers.has(question.layer)) {
- return best;
- }
- if (
- question.exclusiveGroup &&
- excludedGroups.has(question.exclusiveGroup)
- ) {
- return best;
- }
- const gain = informationGain(belief, q);
- return gain > best.gain ? { index: q, gain } : best;
- },
- { index: -1, gain: -1 },
- ).index;
+ // 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. */