// onboarding.jsx — First-run customer onboarding: survey → tailoring splash → guided tutorial. // // Scope (broadened 2026-06-05): Chrono now serves school, solo-dev, // project management, personal growth, anti-procrastination, fitness/gym, // and general time management. The survey captures the user's life-domain // mix; the tutorial uses those answers to surface domain-specific tips. // ═══════════════════════════════════════════════════════════════════════ // Root orchestrator — sequences survey → splash → tutorial → done. // ═══════════════════════════════════════════════════════════════════════ function OnboardingApp({ theme, onComplete }) { // Auth is the FIRST step of onboarding so the login/signup screen never // overlaps the survey/tutorial. Already-signed-in users (or builds without // the accounts backend) skip straight to the survey. const authed = !!(window.chronoAuth && window.chronoAuth.isAuthed()); const startPhase = (window.AuthGate && !authed) ? 'auth' : 'survey'; const [phase, setPhase] = React.useState(startPhase); // 'auth' | 'survey' | 'splash' | 'tutorial' const [answers, setAnswers] = React.useState({}); if (phase === 'auth') { return setPhase('survey')} />; } if (phase === 'survey') { return { setAnswers(a); setPhase('splash'); }}/>; } if (phase === 'splash') { return setPhase('tutorial')}/>; } return { setOnboarding({ done: true, tutorialDone: true, answers }); onComplete(); }}/>; } // ═══════════════════════════════════════════════════════════════════════ // SURVEY — multi-step, Arc-style. // ═══════════════════════════════════════════════════════════════════════ function SurveyFlow({ theme, onComplete }) { const [step, setStep] = React.useState(0); const [answers, setAnswers] = React.useState({}); const update = (patch) => setAnswers(a => ({ ...a, ...patch })); // Some steps are conditional on prior answers (gym goal only shows if the // user picked "fitness" as a domain; school detail only if student). const steps = React.useMemo(() => SURVEY_STEPS.filter(s => typeof s.when === 'function' ? s.when(answers) : true ), [answers]); const next = () => step < steps.length - 1 ? setStep(s => s + 1) : onComplete(answers); const back = () => step > 0 ? setStep(s => s - 1) : null; const skip = () => next(); const s = steps[step] || steps[steps.length - 1]; const progress = (step + 1) / steps.length; const isFinal = step === steps.length - 1; return (
{/* Ambient backdrop */}
{/* Top bar — logo + progress */}
Chrono
{step + 1} / {steps.length}
{/* Step body */}
{s.eyebrow && (
{s.eyebrow}
)} {s.title && (

{s.title}

)} {s.subtitle && (

{s.subtitle}

)}
{s.render({ answers, update, theme, next })}
{/* Bottom action bar */}
{step > 0 && ( )}
{s.skippable !== false && step > 0 && !isFinal && ( )}
); } // ─── Shared survey primitives ─────────────────────────────────────────── function Field({ theme, label, value, onChange, placeholder, type = 'text' }) { return (
onChange(e.target.value)} placeholder={placeholder} style={{ appearance: 'none', outline: 'none', padding: '12px 14px', borderRadius: 12, background: theme.isDark ? 'rgba(255,255,255,0.04)' : 'rgba(10,12,20,0.04)', border: `0.5px solid ${theme.hairline}`, color: theme.text, fontFamily: ChronoTokens.font.ui, fontSize: 15, fontWeight: 500, }}/>
); } function CardSelect({ theme, options, value, onChange, columns = 2 }) { return (
{options.map(o => { const sel = value === o.v; return ( ); })}
); } function PillSelect({ theme, options, value, onChange }) { return (
{options.map(o => { const sel = value === o.v; return ( ); })}
); } function ChipMulti({ theme, options, value = [], onChange }) { const toggle = (v) => { onChange(value.includes(v) ? value.filter(x => x !== v) : [...value, v]); }; return (
{options.map(o => { const sel = value.includes(o.v); return ( ); })}
); } function ToggleRow({ theme, label, sub, value, onChange }) { return (
{label}
{sub &&
{sub}
}
); } // ─── The survey steps ─────────────────────────────────────────────────── const isStudent = (a) => a.role === 'undergrad' || a.role === 'grad' || a.role === 'hs'; const hasDomain = (a, k) => Array.isArray(a.domains) && a.domains.includes(k); const SURVEY_STEPS = [ // 1. Welcome { id: 'welcome', skippable: false, render: ({ theme }) => (
Welcome

Let's tune Chrono
to how you actually live.

School, side projects, the gym, deep work — Chrono will plan around all of it. A short survey, about two minutes.

), }, // Identity (name + email) is collected on the sign-up page now, so the // survey skips straight from welcome to role. // 3. Role — broadened { id: 'role', eyebrow: 'Your role', title: 'What describes you best?', subtitle: "Sets scheduling defaults, AI behaviour, and which categories ship enabled.", render: ({ answers, update, theme }) => ( update({ role: v })} options={[ { v: 'undergrad', label: 'Undergraduate', desc: "Bachelor's degree in progress" }, { v: 'grad', label: 'Graduate student', desc: "Master's, PhD, or professional" }, { v: 'hs', label: 'High school', desc: 'Or pre-college program' }, { v: 'solo-dev', label: 'Solo developer', desc: 'Building software on your own time' }, { v: 'pm', label: 'Project / product manager', desc: 'Coordinating teams and deliverables' }, { v: 'pro', label: 'Working professional', desc: 'Day job + side commitments' }, { v: 'creator', label: 'Creator / freelancer', desc: 'Writer, designer, founder, athlete' }, { v: 'other', label: 'Something else', desc: 'Just here to run my life better' }, ]}/> ), }, // 4. Life domains — NEW, the heart of the broadening { id: 'domains', eyebrow: 'Life domains', title: 'What do you want Chrono to plan around?', subtitle: 'Pick everything that matters. The AI weighs these when proposing blocks.', render: ({ answers, update, theme }) => ( update({ domains: v })} options={[ { v: 'school', label: 'Schoolwork' }, { v: 'work', label: 'Day job / work projects' }, { v: 'sidebuild', label: 'Side projects / coding' }, { v: 'pm', label: 'Project management' }, { v: 'gym', label: 'Fitness / gym' }, { v: 'growth', label: 'Personal growth' }, { v: 'habits', label: 'Habits & routines' }, { v: 'hobbies', label: 'Hobbies / creative time' }, { v: 'social', label: 'Friends & family' }, { v: 'errands', label: 'Errands & admin' }, ]}/> ), }, // 5. School detail (conditional) { id: 'school', when: isStudent, eyebrow: 'Your school', title: 'Where are you studying?', subtitle: 'Used to connect Canvas and pre-fill the Study category. Skip if you\'d rather not say.', render: ({ answers, update, theme }) => (
update({ school: v })} placeholder="e.g. University of Illinois"/> update({ major: v })} placeholder="e.g. Computer Science"/> update({ year: v })} placeholder="e.g. Junior · 2026"/>
), }, // 6. Class load (conditional) { id: 'classload', when: isStudent, eyebrow: 'Course load', title: 'How many classes this term?', render: ({ answers, update, theme }) => ( update({ classes: v })} options={[ { v: '1-2', label: '1–2 classes' }, { v: '3-4', label: '3–4 classes' }, { v: '5-6', label: '5–6 classes' }, { v: '7+', label: '7 or more' }, ]}/> ), }, // 7. Solo-dev stack (conditional) { id: 'devstack', when: (a) => a.role === 'solo-dev' || hasDomain(a, 'sidebuild'), eyebrow: 'Side projects', title: 'What are you shipping?', subtitle: 'Free-form. The AI uses this to label deep-work blocks.', render: ({ answers, update, theme }) => ( update({ devProject: v })} placeholder="e.g. A SaaS for kitesurfers, Rust + SvelteKit"/> ), }, // 8. PM context (conditional) { id: 'pmcontext', when: (a) => a.role === 'pm' || hasDomain(a, 'pm'), eyebrow: 'Project management', title: 'What are you running?', subtitle: 'Used to template recurring meetings and sprint blocks.', render: ({ answers, update, theme }) => ( <> update({ pmTeam: v })} placeholder="e.g. Mobile checkout team"/>
Cadence
update({ pmCadence: v })} options={[ { v: 'weekly', label: 'Weekly sprint' }, { v: 'biweekly', label: 'Two-week sprint' }, { v: 'monthly', label: 'Monthly cycle' }, { v: 'kanban', label: 'Continuous / kanban' }, ]}/>
), }, // 9. Gym goal (conditional on domain) { id: 'gym', when: (a) => hasDomain(a, 'gym'), eyebrow: 'Training', title: 'What\'s the goal at the gym?', subtitle: 'The AI protects training slots and your recovery days.', render: ({ answers, update, theme }) => ( <> update({ gymGoal: v })} options={[ { v: 'strength', label: 'Strength / lifting', desc: 'Heavy compound work, 3–5 days/week' }, { v: 'hypertrophy', label: 'Hypertrophy', desc: 'Body composition, 4–6 days/week' }, { v: 'cardio', label: 'Cardio / endurance', desc: 'Runs, rides, conditioning' }, { v: 'general', label: 'Stay active', desc: '2–3 sessions, mixed' }, ]}/>
Sessions per week
update({ gymPerWeek: v })} options={['2','3','4','5','6'].map(n => ({ v: n, label: `${n}×` }))}/>
), }, // 10. Schedule density { id: 'density', eyebrow: 'Daily rhythm', title: 'How busy is a typical weekday?', subtitle: 'Drives buffer minutes and how aggressively the AI protects focus blocks.', render: ({ answers, update, theme }) => ( update({ density: v })} options={[ { v: 'light', label: 'Light', desc: 'Lots of open time' }, { v: 'moderate', label: 'Moderate', desc: 'A few fixed commitments' }, { v: 'packed', label: 'Packed', desc: 'Back-to-back morning to evening' }, { v: 'overwhelm', label: 'Overwhelming', desc: 'Need help pulling the day apart' }, ]}/> ), }, // 11. Sleep schedule { id: 'sleep', eyebrow: 'Sleep rhythm', title: 'When do you wake up and wind down?', subtitle: "Sets your work-hour window so the AI doesn't schedule things while you're asleep.", render: ({ answers, update, theme }) => (
update({ wake: v })}/> update({ bed: v })}/>
), }, // 12. Focus rhythm { id: 'focus', eyebrow: 'Focus rhythm', title: 'When do you focus best?', subtitle: 'The AI biases deep-work suggestions toward your peak window.', render: ({ answers, update, theme }) => ( <> update({ peak: v })} options={[ { v: 'morning', label: 'Early morning', desc: '5–10 AM' }, { v: 'midday', label: 'Midday', desc: '10 AM – 2 PM' }, { v: 'afternoon', label: 'Afternoon', desc: '2–6 PM' }, { v: 'night', label: 'Night owl', desc: '8 PM – 2 AM' }, ]}/>
Ideal session length
update({ sessionLen: v })} options={['15','25','45','60','90','120'].map(n => ({ v: n, label: `${n} min` }))}/> ), }, // 13. Procrastination — NEW { id: 'procrastination', eyebrow: 'Friction', title: 'Where do you tend to stall?', subtitle: 'No judgement. The AI will nudge differently if you tell us where you get stuck.', render: ({ answers, update, theme }) => ( <> update({ procrastination: v })} options={[ { v: 'starting', label: 'Hard to start tasks' }, { v: 'overwhelm', label: 'Get overwhelmed by big projects' }, { v: 'context', label: 'Lose context between sessions' }, { v: 'distraction',label: 'Phone / social media distraction' }, { v: 'perfection', label: 'Perfectionism stalls me' }, { v: 'energy', label: 'Low energy after work / class' }, ]}/>
update({ nudges: v })}/>
), }, // 14. Breaks + duration { id: 'breaks', eyebrow: 'Recovery', title: 'How long are your breaks?', subtitle: 'Used to space focus sessions and to set your AI buffer between events.', render: ({ answers, update, theme }) => ( <> update({ breakLen: v })} options={['5','10','15','20','30'].map(n => ({ v: n, label: `${n} min` }))}/>
update({ autoBreaks: v })}/>
), }, // 15. AI personality { id: 'ai', eyebrow: 'AI personality', title: 'How should Chrono talk to you?', subtitle: 'Change this any time in Settings.', render: ({ answers, update, theme }) => ( update({ personality: v })} options={[ { v: 'concierge', label: 'Concierge', desc: 'Warm, proactive, surfaces ideas before you ask' }, { v: 'copilot', label: 'Co-pilot', desc: 'Direct, only speaks up when it matters' }, { v: 'silent', label: 'Silent operator', desc: 'No suggestions, just executes what you ask' }, ]} columns={1}/> ), }, // 16. Display preferences { id: 'display', eyebrow: 'Display', title: 'A few visual choices.', subtitle: 'Toggle anything later in Settings.', render: ({ answers, update, theme }) => (
Week starts on
update({ weekStart: v })} options={[{v:'sun',label:'Sunday'},{v:'mon',label:'Monday'}]}/>
Time format
update({ timeFmt: v })} options={[{v:'12',label:'12-hour · 3:30 PM'},{v:'24',label:'24-hour · 15:30'}]}/>
Notification lead time
update({ lead: parseInt(v,10) })} options={['0','5','10','15','30'].map(n => ({v:n, label: n==='0' ? 'At start' : `${n} min before`}))}/>
update({ holidays: v })}/>
), }, // 17. Integrations { id: 'integrations', eyebrow: 'Integrations', title: 'What should we plug in?', subtitle: 'Canvas connects now — the rest are on the way. Manage anytime in Profile.', render: ({ answers, update, theme }) => ( update({ integrations: v })} options={[ { v: 'canvas', label: 'Canvas LMS' }, { v: 'google', label: 'Google Calendar (soon)' }, { v: 'outlook', label: 'Outlook 365 (soon)' }, { v: 'icloud', label: 'iCloud (soon)' }, { v: 'github', label: 'GitHub (soon)' }, { v: 'linear', label: 'Linear (soon)' }, ]}/> ), }, // 18. Goals — broadened beyond school { id: 'goals', eyebrow: 'Outcomes', title: 'What do you want Chrono to help with?', subtitle: 'Pick all that apply. This shapes which insights the AI surfaces over time.', render: ({ answers, update, theme }) => ( update({ goals: v })} options={[ { v: 'deadlines', label: 'Stay on top of deadlines' }, { v: 'shipping', label: 'Actually ship side projects' }, { v: 'procrast', label: 'Procrastinate less' }, { v: 'gym', label: 'Train consistently' }, { v: 'sleep', label: 'Protect sleep' }, { v: 'deepwork', label: 'More deep-work hours' }, { v: 'habits', label: 'Build habits that stick' }, { v: 'stress', label: 'Reduce schedule stress' }, { v: 'meetings', label: 'Run better meetings' }, { v: 'tracking', label: 'Track progress over time' }, { v: 'social', label: 'Plan time with friends' }, { v: 'learning', label: 'Learn / read more' }, ]}/> ), }, ]; // ═══════════════════════════════════════════════════════════════════════ // TAILORING SPLASH — 20s, actually applies answers to stores in stages. // ═══════════════════════════════════════════════════════════════════════ function TailoringSplash({ theme, answers, onDone }) { const TOTAL_MS = 20000; const STAGES = React.useMemo(() => buildTailoringStages(answers), [answers]); const [progress, setProgress] = React.useState(0); const [stageIdx, setStageIdx] = React.useState(0); const startedAt = React.useRef(performance.now()); React.useEffect(() => { let raf; const tick = () => { const elapsed = performance.now() - startedAt.current; const p = Math.min(1, elapsed / TOTAL_MS); setProgress(p); const idx = Math.min(STAGES.length - 1, Math.floor(p * STAGES.length)); setStageIdx(idx); if (p >= 1) { STAGES.forEach(s => s.apply && s.apply()); onDone(); return; } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [STAGES, onDone]); const appliedRef = React.useRef(new Set()); React.useEffect(() => { for (let i = 0; i <= stageIdx; i++) { if (!appliedRef.current.has(i)) { appliedRef.current.add(i); STAGES[i].apply && STAGES[i].apply(); } } }, [stageIdx, STAGES]); const huePulse = Math.floor(progress * 60); return (
{Math.floor(progress * 100)}%
Tailoring Chrono

Setting up your workspace.

{STAGES[stageIdx]?.label || ''}
{STAGES.map((s, i) => { const done = i < stageIdx; const active = i === stageIdx; return (
{done ? '✓' : ''} {s.label}
); })}
); } function buildTailoringStages(a) { const out = []; if (a.name || a.email) { out.push({ label: 'Saving your identity…', apply: () => setProfile({ name: a.name || 'Your name', email: a.email || 'you@chrono.app', }), }); } else { out.push({ label: 'Creating your profile…', apply: () => {} }); } out.push({ label: 'Configuring focus rhythm…', apply: () => { const sessionLen = parseInt(a.sessionLen || '25', 10); const breakLen = parseInt(a.breakLen || '10', 10); setSettings({ focusSessionMin: sessionLen, defaultDuration: Math.max(sessionLen, 30), aiBufferMin: breakLen, }); }, }); out.push({ label: 'Mapping your sleep window…', apply: () => setSettings({ workStart: a.wake || '08:00', workEnd: a.bed || '23:00', }), }); out.push({ label: 'Tuning AI personality…', apply: () => setSettings({ aiPersonality: a.personality || 'concierge' }), }); if (Array.isArray(a.domains) && a.domains.includes('gym')) { out.push({ label: 'Protecting training slots…', apply: () => {/* gym preference stored with onboarding answers */}, }); } if (Array.isArray(a.procrastination) && a.procrastination.length) { out.push({ label: 'Calibrating momentum nudges…', apply: () => {/* nudge toggles stored with onboarding answers */}, }); } out.push({ label: 'Applying display preferences…', apply: () => setSettings({ weekStartsOn: a.weekStart === 'mon' ? 1 : 0, timeFormat: a.timeFmt === '24' ? '24' : '12', notifLead: typeof a.lead === 'number' ? a.lead : 10, showHolidays: a.holidays !== false, }), }); if (Array.isArray(a.integrations) && a.integrations.length) { out.push({ label: 'Queueing integrations…', apply: () => { // Google / Outlook / iCloud are "coming soon" — selecting them just // records interest; only Canvas actually connects today. if (a.integrations.includes('canvas')) { const courses = (window.mockCanvasCourses ? window.mockCanvasCourses() : []); setIntegrations({ canvas: true, canvasSchool: a.school || 'My school', canvasCourses: courses, }); if (window.setCanvasEvents) window.setCanvasEvents(courses); // surface due dates on the calendar } }, }); } else { out.push({ label: 'Skipping integrations for now…', apply: () => {} }); } out.push({ label: 'Calibrating density and schedule pressure…', apply: () => { const buffer = ({ light: 5, moderate: 10, packed: 15, overwhelm: 20 })[a.density] || 10; setSettings({ aiBufferMin: buffer }); }, }); out.push({ label: 'Indexing your goals and domains…', apply: () => {/* goals + domains stored with onboarding answers */}, }); out.push({ label: 'Almost ready…', apply: () => {}, }); return out; } // ═══════════════════════════════════════════════════════════════════════ // GUIDED TUTORIAL — spotlight + tooltip walkthrough. // Steps are computed from survey answers so tips match the user's mix. // ═══════════════════════════════════════════════════════════════════════ function buildTutorialSteps(answers = {}) { const a = answers; const has = (k) => Array.isArray(a.domains) && a.domains.includes(k); const role = a.role || ''; const personality = a.personality || 'concierge'; // Always-on baseline. const steps = [ { view: 'week', target: 'sidebar', title: 'Your toolkit', body: 'Switch between Week, Month, Focus, AI, and your account from the sidebar.' }, { view: 'week', target: 'week-grid', title: 'Week view', body: 'Your full week, scrollable. Click any event for details, edit, reschedule, or delete.' }, { view: 'week', target: 'new-event', title: 'Create events', body: 'Hit + to open the composer. Your default duration from setup is pre-filled.' }, ]; // Domain-aware tips. Each only fires if the user opted in. if (has('school') || role === 'undergrad' || role === 'grad' || role === 'hs') { steps.push({ view: 'week', target: 'week-grid', title: 'School blocks', body: 'Tag events as Study or Canvas — assignments pulled from Canvas land on their due date and the AI will reverse-engineer study blocks back from there.' }); } if (has('sidebuild') || role === 'solo-dev') { steps.push({ view: 'week', target: 'week-grid', title: 'Deep-work blocks for side projects', body: `Long blocks beat scattered hour-here, hour-there. Ask the AI for a 90-min Deep work block${a.devProject ? ` on ${a.devProject}` : ''} during your peak window.` }); } if (has('pm') || role === 'pm') { steps.push({ view: 'week', target: 'week-grid', title: 'Project management cadence', body: `${a.pmCadence === 'weekly' ? 'Sprint planning every Monday, retro every Friday — ' : ''}Ask Chrono to template your standups, reviews, and 1:1s. Recurring events live alongside one-offs.` }); } if (has('gym')) { const per = a.gymPerWeek || '3'; const goal = a.gymGoal === 'strength' ? 'lifting' : a.gymGoal === 'hypertrophy' ? 'hypertrophy' : a.gymGoal === 'cardio' ? 'cardio' : 'training'; steps.push({ view: 'week', target: 'week-grid', title: 'Training stays on the calendar', body: `${per}× ${goal} sessions per week, locked in. The AI won't pile work into your training slots, and never proposes a workout that collides with your bedtime.` }); } if (has('growth') || has('habits')) { steps.push({ view: 'week', target: 'week-grid', title: 'Habits over heroics', body: 'Daily 15-min reading, journaling, or stretching blocks beat 90-min Sunday catch-ups. Ask the AI to "anchor a 15-min reading block after lunch."' }); } // Focus, AI, profile, settings — always shown. steps.push({ view: 'focus', target: null, title: 'Focus sessions', body: 'Pick an event from the picker — the timer length matches the event duration. Start/stop with the play button.' }); // Anti-procrastination — tailored tip if any procrastination signal flagged. if (Array.isArray(a.procrastination) && a.procrastination.length) { const tip = a.procrastination.includes('starting') ? 'Tell the AI "I can\'t start" — it will propose a 15-min starter block instead of the full task.' : a.procrastination.includes('overwhelm') ? 'Ask the AI to break a big project into the smallest next step. It will propose just that — not the whole tree.' : a.procrastination.includes('perfection') ? 'Set a 25-min "draft only" block. Done beats perfect — the AI won\'t schedule a polish pass until you ask.' : 'When momentum stalls, the AI will surface the next smallest step rather than the whole task.'; steps.push({ view: 'week', target: 'ai-area', title: 'Anti-procrastination, built in', body: tip }); } steps.push({ view: 'week', target: 'ai-area', title: 'AI assistant', body: `Plan your week, surface conflicts, propose focus blocks. ${personality === 'concierge' ? 'Concierge mode will anticipate next steps.' : personality === 'copilot' ? 'Co-pilot mode pairs with you on planning.' : 'Silent operator only speaks when asked.'} Show or hide it anytime with the AI button at the top right.` }); steps.push({ view: 'profile', target: null, title: 'Your profile', body: 'Edit your name, email, and integrations any time. Stats update live from your event data.' }); steps.push({ view: 'settings', target: null, title: 'Tune anything', body: "Every setting Chrono exposes — show/hide holidays, default duration, AI buffer, and more. That's your tour." }); return steps; } function GuidedTutorial({ theme, answers, onDone }) { const TUTORIAL_STEPS = React.useMemo(() => buildTutorialSteps(answers), [answers]); const [step, setStep] = React.useState(0); const s = TUTORIAL_STEPS[step]; const [rect, setRect] = React.useState(null); // Force the assistant popout open for the whole tour so new users see it, // then close it again on exit (its default state is hidden). React.useEffect(() => { setTutorialAI(true); return () => setTutorialAI(false); }, []); React.useEffect(() => { setTutorialView(s.view); // Keep the popout open if a later step closed/replaced the view. setTutorialAI(true); }, [step]); React.useEffect(() => { let raf; const measure = () => { if (!s.target) { setRect(null); return; } const el = document.querySelector(`[data-tutorial-id="${s.target}"]`); if (!el) { setRect(null); return; } const r = el.getBoundingClientRect(); // gBCR is in scaled (visual) px under the dynamic UI zoom; the overlay // positions in layout px (re-scaled at render) — convert back. const sc = window.__chronoScale || 1; setRect({ x: r.left / sc, y: r.top / sc, w: r.width / sc, h: r.height / sc }); }; raf = requestAnimationFrame(() => raf = requestAnimationFrame(measure)); window.addEventListener('resize', measure); return () => { cancelAnimationFrame(raf); window.removeEventListener('resize', measure); }; }, [step]); const next = () => step < TUTORIAL_STEPS.length - 1 ? setStep(s => s + 1) : onDone(); const back = () => step > 0 ? setStep(s => s - 1) : null; const tipPos = (() => { if (!rect) return { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' }; const TOOLTIP_W = 360, GAP = 16; const sc = window.__chronoScale || 1; const vw = window.innerWidth / sc, vh = window.innerHeight / sc; // virtual viewport (layout px) let left = rect.x + rect.w + GAP; let top = rect.y + rect.h / 2 - 80; if (left + TOOLTIP_W > vw - 16) { left = rect.x - TOOLTIP_W - GAP; if (left < 16) { left = Math.max(16, Math.min(vw - TOOLTIP_W - 16, rect.x + rect.w / 2 - TOOLTIP_W / 2)); top = rect.y + rect.h + GAP; } } top = Math.max(16, Math.min(vh - 200, top)); return { left, top }; })(); return (
{rect ? (
) : (
)}
Tour · {step + 1} of {TUTORIAL_STEPS.length}
{s.title}
{s.body}
{step > 0 && ( )}
); } Object.assign(window, { OnboardingApp });