feat(desktop): add welcome page to onboarding flow

- Add Welcome step before the 4-step onboarding flow
- Welcome page introduces Multica with brand title (Playfair Display font)
- Display three core principles: Your AI, Your Machine, Your Control
- Add entrance spin animation to MulticaIcon component
- Welcome page has no stepper, only shown after clicking "Start Exploring"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-11 13:46:21 +08:00
parent 4961604f31
commit 22db61876b
11 changed files with 209 additions and 95 deletions

View file

@ -4,6 +4,14 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Multica</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root {
--font-brand: "Playfair Display", serif;
}
</style>
</head>
<body>
<div id="root"></div>

View file

@ -1,13 +1,12 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import { Input } from '@multica/ui/components/ui/input'
import { Badge } from '@multica/ui/components/ui/badge'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft02Icon, Loading03Icon } from '@hugeicons/core-free-icons'
import { useChannels } from '../../hooks/use-channels'
import { TutorialStep } from '../../components/onboarding/tutorial-step'
import { useOnboardingStore } from '../../stores/onboarding'
import { useChannels } from '../../../hooks/use-channels'
import { TutorialStep } from '../../../components/onboarding/tutorial-step'
import { useOnboardingStore } from '../../../stores/onboarding'
function statusVariant(
status: string
@ -24,8 +23,12 @@ function statusVariant(
}
}
export default function ConnectStep() {
const navigate = useNavigate()
interface ConnectStepProps {
onNext: () => void
onBack: () => void
}
export default function ConnectStep({ onNext, onBack }: ConnectStepProps) {
const { states, config, saveToken, loading: channelLoading } = useChannels()
const { setClientConnected } = useOnboardingStore()
@ -57,16 +60,13 @@ export default function ConnectStep() {
setSaving(false)
}
const handleContinue = () => navigate('/onboarding/try-it')
const handleBack = () => navigate('/onboarding/setup')
return (
<div className="h-full flex">
{/* Left column */}
<div className="flex-1 flex items-center justify-center px-12 py-8">
<div className="max-w-md w-full space-y-6">
<button
onClick={handleBack}
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
@ -139,11 +139,11 @@ export default function ConnectStep() {
<div className="flex justify-end gap-2">
{!hasToken && (
<Button size="lg" variant="ghost" onClick={handleContinue}>
<Button size="lg" variant="ghost" onClick={onNext}>
Skip
</Button>
)}
<Button size="lg" onClick={handleContinue} disabled={!isRunning}>
<Button size="lg" onClick={onNext} disabled={!isRunning}>
Continue
</Button>
</div>

View file

@ -1,4 +1,3 @@
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import {
FolderOpenIcon,
@ -6,9 +5,9 @@ import {
AiBrainIcon,
Database01Icon,
} from '@hugeicons/core-free-icons'
import { AcknowledgementItem } from '../../components/onboarding/permission-item'
import { PrivacyPanel } from '../../components/onboarding/privacy-panel'
import { useOnboardingStore } from '../../stores/onboarding'
import { AcknowledgementItem } from '../../../components/onboarding/permission-item'
import { PrivacyPanel } from '../../../components/onboarding/privacy-panel'
import { useOnboardingStore } from '../../../stores/onboarding'
const acknowledgementItems = [
{
@ -41,15 +40,14 @@ const acknowledgementItems = [
},
]
export default function PermissionsStep() {
const navigate = useNavigate()
interface PermissionsStepProps {
onNext: () => void
}
export default function PermissionsStep({ onNext }: PermissionsStepProps) {
const { acknowledgements, allAcknowledged, setAcknowledgement } =
useOnboardingStore()
const handleContinue = () => {
navigate('/onboarding/setup')
}
return (
<div className="h-full flex">
{/* Left column — main content, centered both axes */}
@ -83,7 +81,7 @@ export default function PermissionsStep() {
<div className="flex justify-end">
<Button
size="lg"
onClick={handleContinue}
onClick={onNext}
disabled={!allAcknowledged}
>
Continue

View file

@ -1,17 +1,20 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft02Icon } from '@hugeicons/core-free-icons'
import { useProvider } from '../../hooks/use-provider'
import { ApiKeyDialog } from '../../components/api-key-dialog'
import { OAuthDialog } from '../../components/oauth-dialog'
import { ProviderSetup } from '../../components/onboarding/provider-setup'
import { TutorialStep } from '../../components/onboarding/tutorial-step'
import { useOnboardingStore } from '../../stores/onboarding'
import { useProvider } from '../../../hooks/use-provider'
import { ApiKeyDialog } from '../../../components/api-key-dialog'
import { OAuthDialog } from '../../../components/oauth-dialog'
import { ProviderSetup } from '../../../components/onboarding/provider-setup'
import { TutorialStep } from '../../../components/onboarding/tutorial-step'
import { useOnboardingStore } from '../../../stores/onboarding'
export default function SetupStep() {
const navigate = useNavigate()
interface SetupStepProps {
onNext: () => void
onBack: () => void
}
export default function SetupStep({ onNext, onBack }: SetupStepProps) {
const { providers, current, loading, error, refresh, setProvider } =
useProvider()
const { setProviderConfigured } = useOnboardingStore()
@ -47,21 +50,13 @@ export default function SetupStep() {
setProviderConfigured(true)
}
const handleContinue = () => {
navigate('/onboarding/connect')
}
const handleBack = () => {
navigate('/onboarding')
}
return (
<div className="h-full flex">
{/* Left column — main content, centered both axes */}
<div className="flex-1 flex items-center justify-center px-12 py-8">
<div className="max-w-md w-full space-y-6">
<button
onClick={handleBack}
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
@ -94,7 +89,7 @@ export default function SetupStep() {
<div className="flex justify-end">
<Button
size="lg"
onClick={handleContinue}
onClick={onNext}
disabled={!hasActiveProvider}
>
Continue

View file

@ -1,12 +1,10 @@
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import { Loading } from '@multica/ui/components/ui/loading'
import { ChatView } from '@multica/ui/components/chat-view'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft02Icon } from '@hugeicons/core-free-icons'
import { SamplePrompt } from '../../components/onboarding/sample-prompt'
import { useOnboardingStore } from '../../stores/onboarding'
import { useLocalChat } from '../../hooks/use-local-chat'
import { SamplePrompt } from '../../../components/onboarding/sample-prompt'
import { useLocalChat } from '../../../hooks/use-local-chat'
const samplePrompts = [
{
@ -26,9 +24,12 @@ const samplePrompts = [
},
]
export default function TryItStep() {
const navigate = useNavigate()
const { completeOnboarding } = useOnboardingStore()
interface TryItStepProps {
onComplete: () => void
onBack: () => void
}
export default function TryItStep({ onComplete, onBack }: TryItStepProps) {
const {
agentId,
initError,
@ -45,22 +46,13 @@ export default function TryItStep() {
resolveApproval,
} = useLocalChat()
const handleComplete = () => {
completeOnboarding()
navigate('/')
}
const handleBack = () => {
navigate('/onboarding/connect')
}
return (
<div className="h-full flex">
{/* Left column — prompts */}
<div className="flex-1 flex items-center justify-center px-12 py-8">
<div className="max-w-md w-full space-y-6">
<button
onClick={handleBack}
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
@ -89,7 +81,7 @@ export default function TryItStep() {
</div>
<div className="flex justify-end">
<Button size="lg" onClick={handleComplete}>
<Button size="lg" onClick={onComplete}>
Open Multica
</Button>
</div>

View file

@ -0,0 +1,65 @@
import { Button } from '@multica/ui/components/ui/button'
import { MulticaIcon } from '@multica/ui/components/multica-icon'
const features = [
{
title: 'Your AI',
description: 'Choose your preferred model. Extend its abilities with Skills.',
},
{
title: 'Your Machine',
description: 'Runs locally on your computer. Your data stays with you.',
},
{
title: 'Your Control',
description: 'You set the boundaries. The AI works within them.',
},
]
interface WelcomeStepProps {
onStart: () => void
}
export default function WelcomeStep({ onStart }: WelcomeStepProps) {
return (
<div className="h-full flex items-center justify-center px-12 py-8">
<div className="max-w-md w-full flex flex-col items-center text-center space-y-6">
{/* Brand Title */}
<div className="flex items-center gap-2.5">
<MulticaIcon animate className="size-4 text-muted-foreground/70" />
<h1 className="text-2xl tracking-wide font-[family-name:var(--font-brand)]">
Welcome to Multica
</h1>
</div>
{/* Intro */}
<p className="text-sm text-muted-foreground leading-relaxed">
An AI assistant that gets things done pulling data, running analysis,
and taking action. Talk to it like a team member.
</p>
{/* Feature List */}
<div className="w-full bg-muted/50 rounded-2xl p-5 space-y-4 text-left">
<p className="text-xs text-muted-foreground/70 uppercase tracking-wider">
Built on three principles
</p>
{features.map((feature) => (
<div key={feature.title} className="space-y-1">
<h2 className="text-sm font-medium text-foreground">
{feature.title}
</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
{feature.description}
</p>
</div>
))}
</div>
{/* CTA Button */}
<Button size="lg" onClick={onStart} className="px-8">
Start Exploring
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,56 @@
import { useNavigate } from 'react-router-dom'
import { Stepper } from '../../components/onboarding/stepper'
import { useOnboardingStore } from '../../stores/onboarding'
import WelcomeStep from './components/welcome-step'
import PermissionsStep from './components/permissions-step'
import SetupStep from './components/setup-step'
import ConnectStep from './components/connect-step'
import TryItStep from './components/try-it-step'
export default function OnboardingPage() {
const navigate = useNavigate()
const { currentStep, nextStep, prevStep, completeOnboarding } = useOnboardingStore()
const handleComplete = () => {
completeOnboarding()
navigate('/')
}
// Welcome step (step 0) has no stepper
if (currentStep === 0) {
return (
<div className="h-dvh flex flex-col bg-background">
{/* Draggable title bar region for macOS */}
<header
className="shrink-0 h-8"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
/>
<main className="flex-1 overflow-auto">
<WelcomeStep onStart={nextStep} />
</main>
</div>
)
}
return (
<div className="h-dvh flex flex-col bg-background">
{/* Draggable title bar region for macOS + stepper */}
<header
className="shrink-0 px-6 pt-3 pb-2"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
{/* Spacer for traffic lights */}
<div className="h-5" />
<Stepper currentStep={currentStep} />
</header>
{/* Step content */}
<main className="flex-1 overflow-auto">
{currentStep === 1 && <PermissionsStep onNext={nextStep} />}
{currentStep === 2 && <SetupStep onNext={nextStep} onBack={prevStep} />}
{currentStep === 3 && <ConnectStep onNext={nextStep} onBack={prevStep} />}
{currentStep === 4 && <TryItStep onComplete={handleComplete} onBack={prevStep} />}
</main>
</div>
)
}

View file

@ -1,32 +0,0 @@
import { Outlet, useLocation } from 'react-router-dom'
import { Stepper, type StepId } from '../../components/onboarding/stepper'
export default function OnboardingLayout() {
const location = useLocation()
// Derive current step from URL path
const pathSegment = location.pathname.split('/').pop() as string
const validSteps: StepId[] = ['permissions', 'setup', 'connect', 'try-it']
const currentStep: StepId = validSteps.includes(pathSegment as StepId)
? (pathSegment as StepId)
: 'permissions'
return (
<div className="h-dvh flex flex-col bg-background">
{/* Draggable title bar region for macOS + stepper */}
<header
className="shrink-0 px-6 pt-3 pb-2"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
{/* Spacer for traffic lights */}
<div className="h-5" />
<Stepper currentStep={currentStep} />
</header>
{/* Step content */}
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
)
}

View file

@ -11,10 +11,14 @@ interface AcknowledgementsState {
interface OnboardingStore {
completed: boolean
forceOnboarding: boolean
currentStep: number
acknowledgements: AcknowledgementsState
allAcknowledged: boolean
providerConfigured: boolean
clientConnected: boolean
setStep: (step: number) => void
nextStep: () => void
prevStep: () => void
setAcknowledgement: (key: keyof AcknowledgementsState, value: boolean) => void
setProviderConfigured: (configured: boolean) => void
setClientConnected: (connected: boolean) => void
@ -27,6 +31,7 @@ export const useOnboardingStore = create<OnboardingStore>()(
(set, get) => ({
completed: false,
forceOnboarding: false,
currentStep: 0,
acknowledgements: {
fileSystem: false,
@ -38,6 +43,10 @@ export const useOnboardingStore = create<OnboardingStore>()(
providerConfigured: false,
clientConnected: false,
setStep: (step) => set({ currentStep: step }),
nextStep: () => set({ currentStep: Math.min(get().currentStep + 1, 4) }),
prevStep: () => set({ currentStep: Math.max(get().currentStep - 1, 0) }),
setAcknowledgement: (key, value) => {
const acknowledgements = { ...get().acknowledgements, [key]: value }
const allAcknowledged = Object.values(acknowledgements).every(Boolean)
@ -48,7 +57,7 @@ export const useOnboardingStore = create<OnboardingStore>()(
setClientConnected: (connected) => set({ clientConnected: connected }),
completeOnboarding: () => set({ completed: true, forceOnboarding: false }),
completeOnboarding: () => set({ completed: true, forceOnboarding: false, currentStep: 0 }),
initForceFlag: async () => {
const flags = await window.electronAPI.app.getFlags()

View file

@ -1,5 +1,12 @@
import { cn } from "@multica/ui/lib/utils";
interface MulticaIconProps extends React.ComponentProps<"span"> {
/**
* If true, play a one-time entrance spin animation (2 seconds).
*/
animate?: boolean;
}
/**
* Pure CSS 8-pointed asterisk icon matching the Multica logo.
* Uses currentColor so it adapts to light/dark themes automatically.
@ -7,11 +14,16 @@ import { cn } from "@multica/ui/lib/utils";
*/
export function MulticaIcon({
className,
animate = false,
...props
}: React.ComponentProps<"span">) {
}: MulticaIconProps) {
return (
<span
className={cn("inline-block size-[1em] hover:animate-spin", className)}
className={cn(
"inline-block size-[1em] hover:animate-spin",
animate && "animate-welcome-spin",
className
)}
aria-hidden="true"
{...props}
>

View file

@ -189,3 +189,14 @@
0%, 100% { box-shadow: 0 0 0 0 var(--tool-running); }
50% { box-shadow: 0 0 0 3px oklch(0.6 0.2 250 / 0); }
}
/* Welcome page: one-time spin animation for icon */
@keyframes welcome-spin {
0% { transform: rotate(0deg); opacity: 0; }
30% { opacity: 1; }
100% { transform: rotate(360deg); opacity: 1; }
}
.animate-welcome-spin {
animation: welcome-spin 2s ease-out forwards;
}