// Assessment funnel — 212 items (187 Likert + 25 AI preference). // REQUIRES: engine/itembank.jsx loaded before this file (sets window.ALL_ITEMS). // Place this file at: export/src/screens/Assessment.jsx (replaces existing file) const LIKERT_LABELS = [ 'Very Inaccurate', 'Moderately Inaccurate', 'Neither', 'Moderately Accurate', 'Very Accurate', ]; // [EDIT 2026-05-11] Onboarding steps moved here from OnboardingScreen — now a pre-flight phase const ONBOARDING_STEPS = [ { title: "Welcome, let's set expectations", body: "This takes about 25 minutes. Answer honestly — the engine runs quality checks for central tendency and acquiescence. There are no right answers.", icon: "◌" }, { title: "What you'll get", body: "A personal profile across 10 dimensions, a blind-spot report tailored to your score pattern, and a personal AI constitution you can load into Claude, ChatGPT, or Gemini.", icon: "◎" }, { title: "Consent & data", body: "Your psychometric data is encrypted at rest. We never surface admin-only (SD3) scores. You can delete your profile at any time.", icon: "▽" }, ]; function AssessmentScreen({ onEnter }) { const isMobile = window.useIsMobile ? window.useIsMobile() : false; // [EDIT 2026-05-11] Pre-flight onboarding phase — 'onboarding' | 'questions' const [phase, setPhase] = React.useState('onboarding'); const [step, setStep] = React.useState(0); const [consents, setConsents] = React.useState({ data: false, pair: false, ai: false }); const [submitting, setSubmitting] = React.useState(false); const [alreadyCompleted, setAlreadyCompleted] = React.useState(false); React.useEffect(() => { try { const user = JSON.parse(localStorage.getItem('kalibr.user') || '{}'); if (user.id) { const cards = JSON.parse(localStorage.getItem('kalibr.be.cards') || '{}'); if (cards[user.id]) { setAlreadyCompleted(true); return; } } } catch (e) {} if (window.kalibrAuth) { fetch('/me/scored-card', { headers: window.kalibrAuth.getHeaders() }) .then(r => r.ok ? r.json() : null) .then(d => { if (d && d.completed) setAlreadyCompleted(true); }) .catch(() => {}); } }, []); // All hooks must be declared before any conditional return const allItems = React.useMemo(() => { const src = window.ALL_ITEMS; if (!src || !src.length) { console.warn('[Kalibr] window.ALL_ITEMS not found — is engine/itembank.jsx loaded?'); return []; } return src.map(it => { if (it.type === 'likert') { return { id: it.id, text: it.text, kind: 'likert' }; } return { id: it.id, text: it.stem, kind: 'mc', options: it.options.map(o => o[1]), letters: it.options.map(o => o[0]), }; }); }, []); React.useEffect(() => { // ChatGPT 5/13 — Non-blocking shared-manifest check keeps frontend item codes aligned with backend validation. if (!allItems.length) return; fetch('/assessment_manifest.json') .then(r => r.ok ? r.json() : null) .then(manifest => { if (!manifest || !Array.isArray(manifest.requiredIds)) return; const ids = allItems.map(it => it.id); const sameLength = ids.length === manifest.expectedResponseCount && ids.length === manifest.requiredIds.length; const sameOrder = sameLength && ids.every((id, idx) => id === manifest.requiredIds[idx]); if (!sameOrder) console.warn('[Kalibr] Assessment manifest mismatch', { frontendCount: ids.length, manifestCount: manifest.requiredIds.length }); }) .catch(() => {}); }, [allItems]); const [answers, setAnswers] = React.useState({}); const PAGE_SIZE = 3; const [pageIdx, setPageIdx] = React.useState(0); const startTimeRef = React.useRef(null); const total = allItems.length; const totalPages = Math.ceil(total / PAGE_SIZE); const pageItems = allItems.slice(pageIdx * PAGE_SIZE, (pageIdx + 1) * PAGE_SIZE); const answered = Object.keys(answers).length; const pct = total ? (answered / total) * 100 : 0; const pageFullyAnswered = pageItems.every(it => answers[it.id] != null); const allAnswered = answered === total; const setVal = (itemId, v) => { setAnswers(prev => ({ ...prev, [itemId]: v })); }; React.useEffect(() => { if (phase !== 'questions') return; if (!pageFullyAnswered || pageIdx >= totalPages - 1) return; const t = setTimeout(() => { setPageIdx(p => p + 1); window.scrollTo(0, 0); }, 600); return () => clearTimeout(t); }, [pageFullyAnswered, pageIdx, totalPages, phase]); if (alreadyCompleted) { return (

Assessment complete

You've already completed the Kalibr assessment. Your results are in your reports.

); } // Dev tool: randomly fill all items const devFill = () => { const next = {}; for (const it of allItems) { if (it.kind === 'likert') { const r = Math.random(); next[it.id] = r < 0.10 ? 1 : r < 0.30 ? 2 : r < 0.55 ? 3 : r < 0.85 ? 4 : 5; } else { const letters = it.letters || ['A', 'B', 'C', 'D', 'E'].slice(0, it.options.length); next[it.id] = letters[Math.floor(Math.random() * letters.length)]; } } setAnswers(next); setPageIdx(totalPages - 1); }; // [EDIT 2026-05-11] finish() — uses real user ID, computes quality metrics client-side, calls backend /score Qwen 5/12 const finish = async () => { if (submitting) return; setSubmitting(true); try { // Use logged-in user's ID so profile/reports are tied to their account let respondentId; let participantName, participantEmail, participantRole, participantOrg; try { const user = JSON.parse(localStorage.getItem('kalibr.user') || '{}'); respondentId = user.id; participantName = user.name || null; participantEmail = user.email || null; participantRole = user.role || null; participantOrg = user.org || null; } catch(e) {} respondentId = respondentId || ('user_' + Date.now()); // Client-side quality metrics (backend may refine these on /score) const likertVals = Object.values(answers).filter(v => typeof v === 'number'); const pctMidpoint = likertVals.length ? Math.round(likertVals.filter(v => v === 3).length / likertVals.length * 100) : 0; const pctAgreement = likertVals.length ? Math.round(likertVals.filter(v => v >= 4).length / likertVals.length * 100) : 0; const elapsedMs = startTimeRef.current != null ? Date.now() - startTimeRef.current : null; const completionMinutes = elapsedMs != null ? Math.round(elapsedMs / 6000) / 10 : null; const card = { respondent: { id: respondentId }, rawResponses: answers, quality: { pctMidpoint, pctAgreement, completionMinutes }, }; const existing = JSON.parse(localStorage.getItem('kalibr.be.cards') || '{}'); existing[respondentId] = { email: participantEmail || 'anonymous', card, at: Date.now() }; localStorage.setItem('kalibr.be.cards', JSON.stringify(existing)); // Send responses and demographic data to backend for scoring and persistence await fetch('/score', { method: 'POST', headers: window.kalibrAuth.getHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ responses: answers, respondent_id: respondentId, participant_name: participantName, participant_email: participantEmail, participant_role: participantRole, participant_org: participantOrg, }), }).catch(err => console.warn('[Kalibr] /score request failed:', err)); } catch (e) { console.warn('[Kalibr] Could not persist to localStorage:', e); } onEnter('completion'); }; // [EDIT 2026-05-11] Show onboarding steps before questions begin if (phase === 'onboarding') { const s = ONBOARDING_STEPS[step]; const last = step === ONBOARDING_STEPS.length - 1; return (
{ONBOARDING_STEPS.map((_, i) => (
))}
{s.icon}

{s.title}

{s.body}

{last && (
{[ ['data', 'Store my responses for scoring and report generation.'], ['pair', 'Let me invite a partner to unlock the pairwise reports.'], ['ai', 'Generate a personal AI constitution and let me copy it off-platform.'], ].map(([k, v]) => ( ))}
)}
{last ? : }
); } // [EDIT 2026-05-11] Loading guard now checks pageItems instead of single item if (!pageItems.length) { return (
Loading assessment…
); } const sectionLabel = pageItems[0].kind === 'likert' ? 'Section 1 · Psychometric items' : 'Section 2 · AI preferences'; return (
{/* ── Header ─────────────────────────────────────────────── */}
{sectionLabel}
{answered}/{total} answered
{/* ── Three items per page, stacked vertically ── */}
{pageItems.map((it, i) => ( setVal(it.id, v)} /> ))}
Answer honestly. The engine runs response-quality checks — if more than 55% of your answers sit at the midpoint, your report gets flagged as Central Tendency.
{/* ── Footer ── */}
Your responses save automatically.
{allAnswered ? ( ) : pageIdx >= totalPages - 1 && ( {total - answered} question{total - answered !== 1 ? 's' : ''} remaining )}
); } // ── AssessmentItem ──────────────────────────────────────────────────── // Handles both likert (1–5 scale) and mc (multiple choice with letters). function AssessmentItem({ item, index, value, onChange }) { return (
{String(index).padStart(2, '0')}
{item.text}
{item.kind === 'likert' ? (
{LIKERT_LABELS.map((l, i) => { const v = i + 1; const selected = value === v; return ( ); })}
) : (
{item.options.map((opt, i) => { const letter = (item.letters && item.letters[i]) || 'ABCDE'[i]; const selected = value === letter; return ( ); })}
)}
); } // ── CompletionScreen ────────────────────────────────────────────────── function CompletionScreen({ onEnter }) { const live = window.useLiveScores ? window.useLiveScores() : null; // Read the card the user just submitted let cardQuality = {}; try { const raw = localStorage.getItem('kalibr.be.cards'); if (raw) { const cards = JSON.parse(raw); const keys = Object.keys(cards); if (keys.length) cardQuality = cards[keys[keys.length - 1]]?.card?.quality || {}; } } catch(e) {} // Backend quality (if /score has already responded) overrides client estimates const backendQ = live?.quality || {}; const qualFlag = backendQ.flag; const pctMidpoint = backendQ.pctMidpoint ?? cardQuality.pctMidpoint; const pctAgreement = backendQ.pctAgreement ?? cardQuality.pctAgreement; const reliability = backendQ.reliability || (live ? 'HIGH' : null); const completionMins = cardQuality.completionMinutes; const fmtTime = (m) => { if (m == null) return '—'; const mins = Math.floor(m), secs = Math.round((m - mins) * 60); return `${mins} min ${secs} s`; }; // Derive display values from quality flag when exact percentages aren't in localStorage // (e.g. after re-login: DB stores flag/reliability but not raw percentages) const midFlagKnown = live && qualFlag && qualFlag !== 'CENTRAL_TENDENCY'; const acqFlagKnown = live && qualFlag && qualFlag !== 'ACQUIESCENCE'; const midDisplay = pctMidpoint != null ? `${pctMidpoint}% midpoint — ${pctMidpoint > 55 ? 'flagged' : 'within range'}` : midFlagKnown ? 'Within range' : qualFlag === 'CENTRAL_TENDENCY' ? 'Flagged — high midpoint use' : 'Computing…'; const acqDisplay = pctAgreement != null ? `${pctAgreement}% agreement — ${pctAgreement > 70 ? 'flagged' : 'within range'}` : acqFlagKnown ? 'Within range' : qualFlag === 'ACQUIESCENCE' ? 'Flagged — high agreement bias' : 'Computing…'; const midStatus = qualFlag === 'CENTRAL_TENDENCY' ? 'warn' : pctMidpoint != null ? (pctMidpoint > 55 ? 'warn' : 'ok') : midFlagKnown ? 'ok' : 'pending'; const acqStatus = qualFlag === 'ACQUIESCENCE' ? 'warn' : pctAgreement != null ? (pctAgreement > 70 ? 'warn' : 'ok') : acqFlagKnown ? 'ok' : 'pending'; const checks = [ { label: 'Completion time', value: fmtTime(completionMins), status: completionMins == null ? 'pending' : 'ok' }, { label: 'Central tendency', value: midDisplay, status: midStatus }, { label: 'Acquiescence check', value: acqDisplay, status: acqStatus }, { label: 'Internal consistency', value: backendQ.internalConsistency || (live ? 'All 10 dimensions passed' : 'Computing…'), status: live ? 'ok' : 'pending' }, { label: 'Reliability flag', value: reliability || 'Computing…', status: reliability ? 'ok' : 'pending' }, ]; // ── Report generation state ────────────────────────────────────────── const paid = window.kalibrIsPaid ? window.kalibrIsPaid() : false; const [genState, setGenState] = React.useState('idle'); // idle | running | done | error const [genError, setGenError] = React.useState(null); const [reportReady, setReportReady] = React.useState( !!(window.__kalibr_gemini_profile && window.__kalibr_gemini_blindspots && window.__kalibr_gemini_constitution) ); const generateReport = () => { if (!live || genState === 'running') return; setGenState('running'); setGenError(null); window.__kalibr_gen_running = true; const basePayload = { dimensions: live.dimensions, scaleMeans: live.scaleMeans || {}, supplementary: live.supplementary || {}, aiPreferences: live.aiPreferences || {}, name: window.__kalibr_participant_name || 'You', email: window.__kalibr_participant_email || '', role: window.__kalibr_participant_role || '', context: window.__kalibr_participant_context || '', }; // Navigate immediately — all 3 fetches run in background in parallel onEnter('profile'); const h = window.kalibrAuth.getHeaders({ 'Content-Type': 'application/json' }); const post = (path) => fetch(path, { method: 'POST', headers: h, body: JSON.stringify(basePayload) }) .then(r => { if (!r.ok) throw new Error(path + ' failed (' + r.status + ')'); return r.json(); }); Promise.all([post('/generate/profile'), post('/generate/blindspots'), post('/generate/constitution')]) .then(([profile, blindspots, constitution]) => { window.__kalibr_gemini_profile = profile; window.__kalibr_gemini_blindspots = blindspots; window.__kalibr_gemini_constitution = constitution; window.__kalibr_gen_running = false; window.dispatchEvent(new CustomEvent('kalibr-reports-ready')); if (window.kalibrToast) window.kalibrToast('Your report is ready — profile, blind spots & AI constitution.'); setGenState('done'); setReportReady(true); }) .catch(err => { window.__kalibr_gen_running = false; setGenError(err.message); setGenState('error'); if (window.kalibrToast) window.kalibrToast('Report generation failed: ' + err.message, 'error'); }); }; const scoresReady = !!live; return ( <>

Assessment complete

Your 10-dimension scores are calculated by a fixed algorithm — deterministic, not AI. The narrative report is where AI comes in.

{checks.map((c, i) => (
{c.label}
{c.value}
{c.status === 'warn' ? 'REVIEW' : c.status === 'pending' ? '…' : 'PASS'}
))}
{/* ── 10-dimension scores ─────────────────────────────────── */} {live ? (
10-dimension scores
{[ ['philosophyCohesion', 'Philosophy Cohesion'], ['driveAlignment', 'Drive Alignment'], ['bondingIndex', 'Bonding Index'], ['adaptiveIntelligence', 'Adaptive Intelligence'], ['volatilityVector', 'Volatility Vector'], ['ambiguityTolerance', 'Ambiguity Tolerance'], ['influenceStyle', 'Influence Style'], ['feedbackOrientation', 'Feedback Orientation'], ['temporalOrientation', 'Temporal Orientation'], ['energyResilience', 'Energy Resilience'], ].map(([id, label]) => { const val = Math.round(live.dimensions[id] || 0); const lock = !paid && id !== 'feedbackOrientation'; return (
{label} {val}
); })}
) : (
Scores calculating…
)} {genError && (
Generation failed: {genError}. Please try again.
)}
{!reportReady ? (
{paid ? ( ) : (
)} {!scoresReady && (
Scores are still being calculated — this takes about 10 seconds.
)}
) : (
)}
); } Object.assign(window, { AssessmentScreen, AssessmentItem, CompletionScreen });