feat: add onboarding step 2 with device cluster animation

New animated step shows "Add more devices, run bigger models" with a
sequenced SVG animation: MacBook appears, Mac Studio flies in, a
connection line forms, and locked model cards (30B, 72B, 405B) unlock
with golden glow as combined compute increases. Existing steps 2-6
renumbered to 3-7.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Alex Cheema
2026-02-21 11:07:45 -08:00
co-authored by Claude Opus 4.6
parent 857cda595b
commit 1403d7dfc8
2 changed files with 332 additions and 23 deletions
+12
View File
@@ -202,6 +202,15 @@
filter: drop-shadow(0 0 3px oklch(0.85 0.18 85 / 0.5));
}
/* Onboarding step 2: connection line between devices */
.onboarding-connection-line {
stroke: oklch(0.85 0.18 85 / 0.5);
stroke-width: 1.5px;
stroke-dasharray: 6, 6;
animation: flowAnimation 1s linear infinite;
filter: drop-shadow(0 0 4px oklch(0.85 0.18 85 / 0.4));
}
.graph-link-active {
stroke: oklch(0.85 0.18 85 / 0.8);
stroke-width: 2px;
@@ -337,6 +346,9 @@ input:focus, textarea:focus {
.cursor-blink {
animation: none;
}
.onboarding-connection-line {
animation: none;
}
*,
*::before,
*::after {
+320 -23
View File
@@ -54,6 +54,7 @@
import { addToast } from "$lib/stores/toast.svelte";
import HeaderNav from "$lib/components/HeaderNav.svelte";
import { fade, fly, slide } from "svelte/transition";
import { tweened } from "svelte/motion";
import { cubicInOut, cubicOut } from "svelte/easing";
import { onMount } from "svelte";
@@ -246,10 +247,43 @@
// ── Onboarding wizard state ──
const ONBOARDING_COMPLETE_KEY = "exo-onboarding-complete";
let onboardingStep = $state(0); // 0 = not in onboarding, 1-6 = wizard steps
let onboardingStep = $state(0); // 0 = not in onboarding, 1-7 = wizard steps
let onboardingModelId = $state<string | null>(null); // model selected during onboarding
const showOnboarding = $derived(onboardingStep > 0);
// ── Step 2 animation state: "Add more devices, run bigger models" ──
let deviceAnimPhase = $state(0); // 0=waiting, 1=macbook, 2=studio joins, 3=connection+mid unlock, 4=big unlock
let showContinueStep2 = $state(false);
const studioX = tweened(560, { duration: 700, easing: cubicOut });
const studioOpacity = tweened(0, { duration: 700, easing: cubicOut });
$effect(() => {
if (onboardingStep === 2) {
deviceAnimPhase = 0;
showContinueStep2 = false;
studioX.set(560, { duration: 0 });
studioOpacity.set(0, { duration: 0 });
const t1 = setTimeout(() => { deviceAnimPhase = 1; }, 100);
const t2 = setTimeout(() => {
deviceAnimPhase = 2;
studioX.set(364);
studioOpacity.set(1);
}, 900);
const t3 = setTimeout(() => { deviceAnimPhase = 3; }, 1700);
const t4 = setTimeout(() => { deviceAnimPhase = 4; }, 2500);
const t5 = setTimeout(() => { showContinueStep2 = true; }, 3500);
return () => {
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(t3);
clearTimeout(t4);
clearTimeout(t5);
};
}
});
// Recommended models for onboarding (sorted by fit, then size desc, limited to 6)
const onboardingModels = $derived.by(() => {
if (models.length === 0) return [];
@@ -265,9 +299,9 @@
});
// Track onboarding instance status for auto-advancing steps.
// Handles cached models: if no download is needed, skip step 4 entirely.
// Handles cached models: if no download is needed, skip step 5 entirely.
$effect(() => {
if (onboardingStep === 4 && instanceCount > 0) {
if (onboardingStep === 5 && instanceCount > 0) {
let anyDownloading = false;
let anyReady = false;
for (const [id, inst] of Object.entries(instanceData)) {
@@ -285,16 +319,16 @@
}
// Model already cached & ready — skip download AND loading steps
if (anyReady) {
onboardingStep = 6;
onboardingStep = 7;
} else if (!anyDownloading) {
// Download finished (or was never needed) but not ready yet
onboardingStep = 5;
onboardingStep = 6;
}
}
});
$effect(() => {
if (onboardingStep === 5 && instanceCount > 0) {
if (onboardingStep === 6 && instanceCount > 0) {
for (const [id, inst] of Object.entries(instanceData)) {
const status = getInstanceDownloadStatus(id, inst);
if (
@@ -302,7 +336,7 @@
status.statusText === "LOADED" ||
status.statusText === "RUNNING"
) {
onboardingStep = 6;
onboardingStep = 7;
break;
}
}
@@ -324,7 +358,7 @@
onboardingModelId = modelId;
onboardingError = null;
selectPreviewModel(modelId);
onboardingStep = 4;
onboardingStep = 5;
// Launch via API
try {
const placementResponse = await fetch(
@@ -333,7 +367,7 @@
if (!placementResponse.ok) {
const errorText = await placementResponse.text();
onboardingError = `Could not place model: ${errorText}`;
onboardingStep = 3;
onboardingStep = 4;
return;
}
const placementData = await placementResponse.json();
@@ -345,14 +379,14 @@
if (!response.ok) {
const errorText = await response.text();
onboardingError = `Failed to launch: ${errorText}`;
onboardingStep = 3;
onboardingStep = 4;
return;
}
setSelectedChatModel(modelId);
recordRecentLaunch(modelId);
} catch (error) {
onboardingError = `Network error: ${error}`;
onboardingStep = 3;
onboardingStep = 4;
}
}
@@ -2624,7 +2658,270 @@
</button>
</div>
{:else if onboardingStep === 2}
<!-- Step 2: Your Devices -->
<!-- Step 2: Add more devices, run bigger models -->
<div
class="flex flex-col items-center w-full max-w-2xl px-8"
in:fade={{ duration: 400 }}
out:fade={{ duration: 200 }}
>
<div class="text-center mb-4">
<h1
class="text-2xl font-sans font-light text-white/90 mb-2 tracking-wide"
>
Add more devices, run bigger models
</h1>
</div>
<!-- Animation stage -->
<div class="relative w-full" style="height: 340px;">
<svg viewBox="0 0 600 340" class="w-full h-full" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="onb-gold-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feFlood flood-color="#FFD700" flood-opacity="0.6" result="color" />
<feComposite in="color" in2="blur" operator="in" result="glow" />
<feMerge>
<feMergeNode in="glow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="onb-device-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood flood-color="#FFD700" flood-opacity="0.3" result="color" />
<feComposite in="color" in2="blur" operator="in" result="glow" />
<feMerge>
<feMergeNode in="glow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<!-- MacBook Device (left side) -->
{#if deviceAnimPhase >= 1}
<g transform="translate(180, 55)" in:fade={{ duration: 600 }}>
<!-- Screen bezel -->
<rect x="0" y="0" width="56" height="38" rx="3"
fill="none" stroke="#FFD700" stroke-width="1.5" filter="url(#onb-device-glow)" />
<!-- Screen interior -->
<rect x="3" y="2" width="50" height="32" rx="1" fill="#0a0a0a" />
<!-- Memory bar inside screen -->
<rect x="6" y="24" width="20" height="6" rx="1" fill="#374151" />
<rect x="6" y="24" width="12" height="6" rx="1" fill="rgba(255,215,0,0.4)" />
<!-- Keyboard base -->
<path d="M -4 41 L 60 41 L 56 48 L 0 48 Z"
fill="none" stroke="#FFD700" stroke-width="1.5" />
<!-- Label -->
<text x="28" y="66" text-anchor="middle"
fill="rgba(255,255,255,0.6)" style="font-size: 10px; font-family: system-ui, sans-serif;">
MacBook Pro
</text>
<text x="28" y="78" text-anchor="middle"
fill="rgba(255,255,255,0.3)" style="font-size: 9px; font-family: 'SF Mono', monospace;">
36 GB
</text>
</g>
{/if}
<!-- Mac Studio Device (right side) - tweened fly-in -->
<g transform="translate({$studioX}, 55)" opacity={$studioOpacity}>
<!-- Studio box -->
<rect x="0" y="0" width="52" height="44" rx="5"
fill="none" stroke="#FFD700" stroke-width="1.5" filter="url(#onb-device-glow)" />
<!-- Inner face -->
<rect x="4" y="4" width="44" height="36" rx="3" fill="#0a0a0a" />
<!-- Memory bar inside -->
<rect x="8" y="28" width="36" height="6" rx="1" fill="#374151" />
<rect x="8" y="28" width="28" height="6" rx="1" fill="rgba(255,215,0,0.4)" />
<!-- Front circle detail -->
<circle cx="26" cy="40" r="2.5" fill="none" stroke="rgba(255,215,0,0.4)" stroke-width="0.8" />
<!-- Label -->
<text x="26" y="66" text-anchor="middle"
fill="rgba(255,255,255,0.6)" style="font-size: 10px; font-family: system-ui, sans-serif;">
Mac Studio
</text>
<text x="26" y="78" text-anchor="middle"
fill="rgba(255,255,255,0.3)" style="font-size: 9px; font-family: 'SF Mono', monospace;">
192 GB
</text>
</g>
<!-- Connection line between devices -->
{#if deviceAnimPhase >= 3}
<line
x1="240" y1="76"
x2="364" y2="76"
class="onboarding-connection-line"
in:fade={{ duration: 400 }}
/>
{/if}
<!-- Combined memory label -->
{#if deviceAnimPhase >= 3}
<text x="302" y="64" text-anchor="middle"
fill="rgba(255,215,0,0.6)" style="font-size: 9px; font-family: 'SF Mono', monospace;"
in:fade={{ duration: 400 }}>
228 GB combined
</text>
{/if}
<!-- Model cards row -->
<g transform="translate(0, 180)">
<!-- Arrows from devices to models -->
{#if deviceAnimPhase >= 3}
<line x1="208" y1="-38" x2="208" y2="-8"
stroke="rgba(255,215,0,0.15)" stroke-width="1" stroke-dasharray="3,3"
in:fade={{ duration: 300 }} />
<line x1="390" y1="-38" x2="390" y2="-8"
stroke="rgba(255,215,0,0.15)" stroke-width="1" stroke-dasharray="3,3"
in:fade={{ duration: 300 }} />
{/if}
<!-- Small model: 8B (visible from phase 1) -->
{#if deviceAnimPhase >= 1}
<g transform="translate(98, 0)" in:fade={{ duration: 500 }}>
<rect x="0" y="0" width="80" height="40" rx="6"
fill="rgba(255,255,255,0.03)" stroke="rgba(255,255,255,0.12)" stroke-width="1" />
<text x="40" y="17" text-anchor="middle"
fill="rgba(255,255,255,0.7)" style="font-size: 11px; font-family: system-ui, sans-serif;">
Qwen3 8B
</text>
<text x="40" y="31" text-anchor="middle"
fill="rgba(255,255,255,0.35)" style="font-size: 9px; font-family: 'SF Mono', monospace;">
4 GB
</text>
</g>
{/if}
<!-- Medium model: 30B (locked → unlocks at phase 3) -->
{#if deviceAnimPhase >= 1}
<g transform="translate(200, 0)" in:fade={{ duration: 500, delay: 200 }}>
<rect x="0" y="0" width="80" height="40" rx="6"
fill={deviceAnimPhase >= 3 ? 'rgba(255,215,0,0.06)' : 'rgba(255,255,255,0.015)'}
stroke={deviceAnimPhase >= 3 ? 'rgba(255,215,0,0.35)' : 'rgba(255,255,255,0.06)'}
stroke-width="1"
filter={deviceAnimPhase >= 3 ? 'url(#onb-gold-glow)' : 'none'}
style="transition: fill 500ms, stroke 500ms, filter 500ms;"
/>
{#if deviceAnimPhase < 3}
<!-- Lock icon -->
<g transform="translate(34, 10)">
<rect x="0" y="8" width="12" height="9" rx="1.5"
fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="1" />
<path d="M 2 8 V 5 C 2 2.5 4 1 6 1 C 8 1 10 2.5 10 5 V 8"
fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="1" />
</g>
{:else}
<text x="40" y="17" text-anchor="middle"
fill="#FFD700" style="font-size: 11px; font-family: system-ui, sans-serif;">
Qwen3 30B
</text>
<text x="40" y="31" text-anchor="middle"
fill="rgba(255,215,0,0.6)" style="font-size: 9px; font-family: 'SF Mono', monospace;">
16 GB
</text>
{/if}
</g>
{/if}
<!-- Large model: 72B (locked → unlocks at phase 4) -->
{#if deviceAnimPhase >= 1}
<g transform="translate(306, 0)" in:fade={{ duration: 500, delay: 400 }}>
<rect x="0" y="0" width="80" height="40" rx="6"
fill={deviceAnimPhase >= 4 ? 'rgba(255,215,0,0.06)' : 'rgba(255,255,255,0.015)'}
stroke={deviceAnimPhase >= 4 ? 'rgba(255,215,0,0.35)' : 'rgba(255,255,255,0.06)'}
stroke-width="1"
filter={deviceAnimPhase >= 4 ? 'url(#onb-gold-glow)' : 'none'}
style="transition: fill 500ms, stroke 500ms, filter 500ms;"
/>
{#if deviceAnimPhase < 4}
<g transform="translate(34, 10)">
<rect x="0" y="8" width="12" height="9" rx="1.5"
fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="1" />
<path d="M 2 8 V 5 C 2 2.5 4 1 6 1 C 8 1 10 2.5 10 5 V 8"
fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="1" />
</g>
{:else}
<text x="40" y="17" text-anchor="middle"
fill="#FFD700" style="font-size: 11px; font-family: system-ui, sans-serif;">
Llama 72B
</text>
<text x="40" y="31" text-anchor="middle"
fill="rgba(255,215,0,0.6)" style="font-size: 9px; font-family: 'SF Mono', monospace;">
36 GB
</text>
{/if}
</g>
{/if}
<!-- Extra large model: 405B (locked → unlocks at phase 4 with extra glow) -->
{#if deviceAnimPhase >= 1}
<g transform="translate(410, 0)" in:fade={{ duration: 500, delay: 600 }}>
<rect x="0" y="0" width="88" height="40" rx="6"
fill={deviceAnimPhase >= 4 ? 'rgba(255,215,0,0.08)' : 'rgba(255,255,255,0.015)'}
stroke={deviceAnimPhase >= 4 ? 'rgba(255,215,0,0.45)' : 'rgba(255,255,255,0.06)'}
stroke-width={deviceAnimPhase >= 4 ? '1.5' : '1'}
filter={deviceAnimPhase >= 4 ? 'url(#onb-gold-glow)' : 'none'}
style="transition: fill 700ms, stroke 700ms, filter 700ms, stroke-width 700ms;"
/>
{#if deviceAnimPhase < 4}
<g transform="translate(38, 10)">
<rect x="0" y="8" width="12" height="9" rx="1.5"
fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="1" />
<path d="M 2 8 V 5 C 2 2.5 4 1 6 1 C 8 1 10 2.5 10 5 V 8"
fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="1" />
</g>
{:else}
<text x="44" y="17" text-anchor="middle"
fill="#FFD700" style="font-size: 11px; font-family: system-ui, sans-serif; font-weight: 600;">
Llama 405B
</text>
<text x="44" y="31" text-anchor="middle"
fill="rgba(255,215,0,0.6)" style="font-size: 9px; font-family: 'SF Mono', monospace;">
203 GB
</text>
{/if}
</g>
{/if}
<!-- "Models you can run" label -->
{#if deviceAnimPhase >= 1}
<text x="300" y="60" text-anchor="middle"
fill="rgba(255,255,255,0.25)" style="font-size: 10px; font-family: system-ui, sans-serif; text-transform: uppercase; letter-spacing: 0.1em;"
in:fade={{ duration: 400 }}>
Models you can run
</text>
{/if}
</g>
</svg>
</div>
<!-- Continue button -->
{#if showContinueStep2}
<button
type="button"
onclick={() => (onboardingStep = 3)}
class="inline-flex items-center gap-2 px-8 py-3 mt-2 bg-exo-yellow text-exo-black font-sans text-sm font-semibold rounded-full hover:brightness-110 hover:shadow-[0_0_24px_rgba(255,215,0,0.2)] transition-all duration-200 cursor-pointer"
in:fade={{ duration: 400 }}
>
Continue
<svg
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
</button>
{/if}
</div>
{:else if onboardingStep === 3}
<!-- Step 3: Your Devices -->
<div
class="flex flex-col items-center w-full max-w-4xl px-8"
in:fade={{ duration: 400 }}
@@ -2661,7 +2958,7 @@
</p>
<button
type="button"
onclick={() => (onboardingStep = 3)}
onclick={() => (onboardingStep = 4)}
class="inline-flex items-center gap-2 px-8 py-3 mt-6 bg-exo-yellow text-exo-black font-sans text-sm font-semibold rounded-full hover:brightness-110 hover:shadow-[0_0_24px_rgba(255,215,0,0.2)] transition-all duration-200 cursor-pointer"
>
Continue
@@ -2680,8 +2977,8 @@
</svg>
</button>
</div>
{:else if onboardingStep === 3}
<!-- Step 3: Choose a Model -->
{:else if onboardingStep === 4}
<!-- Step 4: Choose a Model -->
<div
class="flex flex-col items-center w-full max-w-2xl px-8"
in:fade={{ duration: 400 }}
@@ -2776,8 +3073,8 @@
Browse all models
</button>
</div>
{:else if onboardingStep === 4}
<!-- Step 4: Downloading -->
{:else if onboardingStep === 5}
<!-- Step 5: Downloading -->
<div
class="text-center max-w-lg px-8"
in:fade={{ duration: 400 }}
@@ -2833,8 +3130,8 @@
This may take a few minutes depending on your connection.
</p>
</div>
{:else if onboardingStep === 5}
<!-- Step 5: Loading into memory -->
{:else if onboardingStep === 6}
<!-- Step 6: Loading into memory -->
<div
class="text-center max-w-lg px-8"
in:fade={{ duration: 400 }}
@@ -2861,8 +3158,8 @@
<p class="text-sm text-white/40 font-sans">Almost ready...</p>
</div>
{:else if onboardingStep === 6}
<!-- Step 6: Chat — centered input auto-appears, first message transitions to dashboard -->
{:else if onboardingStep === 7}
<!-- Step 7: Chat — centered input auto-appears, first message transitions to dashboard -->
<div
class="flex flex-col items-center justify-center w-full max-w-2xl px-8"
in:fade={{ duration: 400 }}
@@ -2914,8 +3211,8 @@
{/if}
</div>
<!-- Model Picker Modal (available during onboarding step 3) -->
{#if onboardingStep === 3}
<!-- Model Picker Modal (available during onboarding step 4) -->
{#if onboardingStep === 4}
<ModelPickerModal
isOpen={isModelPickerOpen}
{models}