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.'} + +
+
+ + +
+
+ ); + } + + if (phase === 'picker') { + return ( +
+ + Who are you, really? + + + Pick your type. Patchy will pretend it knew all along. + +
+ {personas.map((persona) => ( + + ))} +
+
+ ); + } + + if (phase === 'tiebreak') { + return ( +
+ {MASCOT_EMOJI} + + I'm torn between these two. + + + Which one feels more like you? + +
+ {tiebreakPersonas.map((persona) => ( + + ))} +
+
+ ); + } + + if (phase === 'reveal' && result) { + const { persona } = result; + return ( +
+ + {persona.emoji} + + + {persona.name} + + + {persona.tagline} + +
+ + +
+
+ ); + } + + return ( +
+ + {MASCOT_EMOJI} + +
+ + {questionText} + +
+ + + +
+
+
+ ); +} + +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} +
+ +
+ )} +
+ ); +} + +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 => ( + +); + 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) => ( - + 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) => ( + ))}
+
); } 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 ( + + ); + })} +
+ +
+ ); + } + 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} + + + ))} +
+ )}
); } 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} - - - ))} -
- )} -
+
- - + + + +
+ {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. */