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:
parent
4961604f31
commit
22db61876b
11 changed files with 209 additions and 95 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
56
apps/desktop/src/renderer/src/pages/onboarding/index.tsx
Normal file
56
apps/desktop/src/renderer/src/pages/onboarding/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue