import { useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react' import { OnboardingShell } from './OnboardingShell' import { Button } from '@/components/ui/button' interface WelcomeScreenProps { mode: 'welcome' | 'vault-missing' missingPath?: string defaultVaultPath: string onCreateVault: () => void onRetryCreateVault: () => void onCreateEmptyVault: () => void onOpenFolder: () => void isOffline: boolean creatingAction: 'template' | 'empty' | null error: string | null canRetryTemplate: boolean } interface WelcomeScreenPresentation { heroBackground: string heroIcon: ReactNode openFolderLabel: string subtitle: string templateDescription: string title: string } type WelcomeActionButtonRef = React.RefObject interface WelcomeAction { disabled: boolean run: () => void } function isWelcomeActivationKey(event: globalThis.KeyboardEvent): boolean { return event.key === 'Enter' || event.key === ' ' } function isWelcomeNavigationKey(event: globalThis.KeyboardEvent): boolean { return event.key === 'Tab' || event.key === 'ArrowDown' || event.key === 'ArrowUp' } function nextWelcomeActionIndex( currentIndex: number, event: globalThis.KeyboardEvent, actionCount: number, ): number { const direction = event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey) ? -1 : 1 return (currentIndex + direction + actionCount) % actionCount } function focusBelongsToWelcomeActions( activeElement: Element | null, actionButtonRefs: WelcomeActionButtonRef[], ): boolean { return activeElement === document.body || actionButtonRefs.some(({ current }) => current === activeElement) } function getFocusedWelcomeActionIndex( activeElement: Element | null, actionButtonRefs: WelcomeActionButtonRef[], ): number { return Math.max( 0, actionButtonRefs.findIndex(({ current }) => current === activeElement), ) } function focusWelcomeAction( actionButtonRefs: WelcomeActionButtonRef[], actionIndex: number, ): void { actionButtonRefs[actionIndex]?.current?.focus() } function triggerWelcomeAction( actionIndex: number, actions: WelcomeAction[], ): void { const action = actions[actionIndex] if (!action?.disabled) action.run() } const CARD_STYLE: React.CSSProperties = { width: 'min(520px, 100%)', background: 'var(--background)', borderRadius: 12, border: '1px solid var(--border)', padding: 48, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 24, } const ICON_WRAP_STYLE: React.CSSProperties = { width: 64, height: 64, borderRadius: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', } const TITLE_STYLE: React.CSSProperties = { fontSize: 28, fontWeight: 700, letterSpacing: -0.5, color: 'var(--foreground)', textAlign: 'center', margin: 0, } const SUBTITLE_STYLE: React.CSSProperties = { fontSize: 14, lineHeight: 1.6, color: 'var(--muted-foreground)', textAlign: 'center', margin: 0, } const DIVIDER_STYLE: React.CSSProperties = { width: '100%', height: 1, background: 'var(--border)', } const OPTION_BTN_STYLE: React.CSSProperties = { width: '100%', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--background)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 14, padding: '14px 16px', textAlign: 'left', transition: 'background 0.15s', } const OPTION_ICON_STYLE: React.CSSProperties = { width: 36, height: 36, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, } const OPTION_LABEL_STYLE: React.CSSProperties = { fontSize: 14, fontWeight: 600, color: 'var(--foreground)', margin: 0, } const OPTION_DESC_STYLE: React.CSSProperties = { fontSize: 12, color: 'var(--muted-foreground)', margin: 0, marginTop: 2, } const ERROR_STYLE: React.CSSProperties = { fontSize: 13, color: 'var(--destructive, #e03e3e)', textAlign: 'center', margin: 0, } const STATUS_STYLE: React.CSSProperties = { fontSize: 13, color: 'var(--muted-foreground)', textAlign: 'center', margin: 0, } const ERROR_BLOCK_STYLE: React.CSSProperties = { width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, } const RETRY_BUTTON_STYLE: React.CSSProperties = { borderRadius: 8, border: '1px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)', cursor: 'pointer', padding: '8px 12px', fontSize: 13, fontWeight: 600, } interface OptionButtonProps { icon: React.ReactNode iconBg: string label: string description: string loadingLabel?: string loadingDescription?: string onClick: () => void disabled: boolean loading?: boolean testId: string autoFocus?: boolean buttonRef?: React.RefObject } function OptionButton({ icon, iconBg, label, description, loadingLabel, loadingDescription, onClick, disabled, loading, testId, autoFocus = false, buttonRef, }: OptionButtonProps) { const [hover, setHover] = useState(false) return ( ) } function getWelcomeScreenPresentation( mode: WelcomeScreenProps['mode'], defaultVaultPath: string, isOffline: boolean, ): WelcomeScreenPresentation { if (mode === 'welcome') { return { heroBackground: 'var(--accent-blue-light, #EBF4FF)', heroIcon: , openFolderLabel: 'Open existing vault', subtitle: 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.', templateDescription: isOffline ? `Requires internet — clone later. Suggested path: ${defaultVaultPath}` : 'Download the getting started vault', title: 'Welcome to Tolaria', } } return { heroBackground: 'var(--accent-yellow-light, #FFF3E0)', heroIcon: , openFolderLabel: 'Choose a different folder', subtitle: 'The vault folder could not be found on disk.\nIt may have been moved or deleted.', templateDescription: isOffline ? `Requires internet — clone later. Suggested path: ${defaultVaultPath}` : 'Download the getting started vault', title: 'Vault not found', } } function useWelcomeActionButtons({ mode, busy, isOffline, onCreateEmptyVault, onOpenFolder, onCreateVault, }: Pick< WelcomeScreenProps, 'mode' | 'isOffline' | 'onCreateEmptyVault' | 'onOpenFolder' | 'onCreateVault' > & { busy: boolean }) { const primaryActionRef = useRef(null) const openFolderActionRef = useRef(null) const templateActionRef = useRef(null) const actionButtonRefs = useMemo( () => [primaryActionRef, openFolderActionRef, templateActionRef], [], ) const actions = useMemo( () => ([ { disabled: false, run: onCreateEmptyVault }, { disabled: false, run: onOpenFolder }, { disabled: isOffline, run: onCreateVault }, ]), [isOffline, onCreateEmptyVault, onCreateVault, onOpenFolder], ) useEffect(() => { if (busy) return // WKWebView can ignore `autoFocus`; move focus explicitly so keyboard-only // onboarding always starts on "Create empty vault". focusWelcomeAction(actionButtonRefs, 0) }, [actionButtonRefs, busy, mode]) useEffect(() => { if (busy) return const handleKeyDown = (event: globalThis.KeyboardEvent) => { const activeElement = document.activeElement if (!focusBelongsToWelcomeActions(activeElement, actionButtonRefs)) return const actionIndex = getFocusedWelcomeActionIndex(activeElement, actionButtonRefs) if (isWelcomeNavigationKey(event)) { event.preventDefault() focusWelcomeAction( actionButtonRefs, nextWelcomeActionIndex(actionIndex, event, actionButtonRefs.length), ) return } if (!isWelcomeActivationKey(event)) return event.preventDefault() triggerWelcomeAction(actionIndex, actions) } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [actionButtonRefs, actions, busy]) return { primaryActionRef, openFolderActionRef, templateActionRef, } } export function WelcomeScreen({ mode, defaultVaultPath, onCreateVault, onRetryCreateVault, onCreateEmptyVault, onOpenFolder, isOffline, creatingAction, error, canRetryTemplate, }: WelcomeScreenProps) { const busy = creatingAction !== null const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline) const { primaryActionRef, openFolderActionRef, templateActionRef } = useWelcomeActionButtons({ mode, busy, isOffline, onCreateEmptyVault, onOpenFolder, onCreateVault, }) return ( <>
{presentation.heroIcon}

{presentation.title}

{presentation.subtitle}

} iconBg="var(--accent-blue-light, #EBF4FF)" label="Create empty vault" description="Start fresh in an empty folder with Tolaria defaults" loadingLabel="Creating vault…" loadingDescription="Preparing Tolaria defaults in the selected folder" onClick={onCreateEmptyVault} disabled={busy} loading={creatingAction === 'empty'} testId="welcome-create-new" autoFocus buttonRef={primaryActionRef} /> } iconBg="var(--accent-green-light, #E8F5E9)" label={presentation.openFolderLabel} description="Point to a folder you already have" onClick={onOpenFolder} disabled={busy} testId="welcome-open-folder" buttonRef={openFolderActionRef} /> } iconBg="var(--accent-purple-light, #F3E8FF)" label="Get started with a template" description={presentation.templateDescription} loadingLabel="Downloading template…" loadingDescription="Cloning the Getting Started vault template" onClick={onCreateVault} disabled={busy || isOffline} loading={creatingAction === 'template'} testId="welcome-create-vault" buttonRef={templateActionRef} />
{creatingAction === 'template' && (

Downloading the Getting Started vault template…

)} {error && (

{error}

{canRetryTemplate && ( )}
)} ) }