From c72a543088b353005f569cfdfd58f16860ab13ff Mon Sep 17 00:00:00 2001 From: Alex Cheema Date: Fri, 20 Feb 2026 10:23:07 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20onboarding=20polish=20=E2=80=94=20smoot?= =?UTF-8?q?h=20transitions,=20game-style=20model=20unlock,=20replay=20butt?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Smooth step transitions: title/subtitle crossfade with tweened opacity instead of display:none or CSS animation remounts (no layout jumps between steps) - Game-style "MODELS UNLOCKED" animation on step 2: chips appear locked (grey), then sequentially light up gold with glow filter and scale pulse - Settled chip state uses white text on dark bg for readability (no yellow-on-yellow) - Chips fade out gently (600ms) before step 3 model block appears (no overlap) - Added Replay button (restart icon) next to Skip button (skip-forward icon) - Fixed onb-fade-in CSS: transform:none instead of translateY(0) to avoid containing block issues with fixed-positioned dropdowns Co-Authored-By: Claude Opus 4.6 --- dashboard/src/app.css | 2 +- dashboard/src/routes/+page.svelte | 557 ++++++++++++++++++++---------- 2 files changed, 385 insertions(+), 174 deletions(-) diff --git a/dashboard/src/app.css b/dashboard/src/app.css index 939e3972..7819f4d7 100644 --- a/dashboard/src/app.css +++ b/dashboard/src/app.css @@ -341,7 +341,7 @@ input:focus, textarea:focus { /* Onboarding smooth fade-in */ @keyframes onb-fade-in { from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } + to { opacity: 1; transform: none; } } /* Respect reduced motion preference */ diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte index f1776442..7484fb75 100644 --- a/dashboard/src/routes/+page.svelte +++ b/dashboard/src/routes/+page.svelte @@ -260,6 +260,20 @@ Math.round(clusterTotalMemoryGB() + SIMULATED_STUDIO_GB), ); + // Models unlocked by adding the second device (sorted ascending — small model first) + const unlockedModels = $derived.by(() => { + if (models.length === 0) return []; + const singleGB = clusterTotalMemoryGB(); + const combinedGB = onboardingCombinedGB; + return models + .filter((m) => { + const sizeGB = getModelSizeGB(m); + return sizeGB > singleGB && sizeGB <= combinedGB; + }) + .sort((a, b) => getModelSizeGB(a) - getModelSizeGB(b)) + .slice(0, 3); + }); + // User device info from topology const userDeviceInfo = $derived.by(() => { if (!data || Object.keys(data.nodes).length === 0) { @@ -280,14 +294,20 @@ let stepTitle = $state(""); let stepTransitioning = $state(false); - // Smooth step transition: fade out, change step, fade in + // Advance to the next onboarding step function advanceStep(target: number) { showContinueButton = false; - stepTransitioning = true; - setTimeout(() => { + if (target <= 5) { + // Steps 1-5 share a persistent SVG canvas — just set the step directly onboardingStep = target; - stepTransitioning = false; - }, 350); + } else { + // Leaving the cinematic sequence — fade out, then switch + stepTransitioning = true; + setTimeout(() => { + onboardingStep = target; + stepTransitioning = false; + }, 350); + } } // Tweened animation values for the persistent SVG canvas @@ -302,6 +322,12 @@ const modelSplitProgress = tweened(0, { duration: 800, easing: cubicInOut }); // 0=unified, 1=fully split const disconnectXOpacity = tweened(0, { duration: 400, easing: cubicOut }); const device1Opacity = tweened(1, { duration: 600, easing: cubicOut }); + const logoOpacity = tweened(1, { duration: 600, easing: cubicOut }); + // Step 2 chip reveal phases: 0→1 chip0 appears, 1→2 chip0 unlocks, 2→3 chips1+2 appear, 3→4 chips1+2 unlock + const chipPhase = tweened(0, { duration: 600, easing: cubicOut }); + const deviceCountOpacity = tweened(0, { duration: 600, easing: cubicOut }); + const titleOpacity = tweened(0, { duration: 500, easing: cubicOut }); + const subtitleOpacity = tweened(0, { duration: 500, easing: cubicOut }); // ── Step 1: "Your device" — one device fades in, centered ── $effect(() => { @@ -318,26 +344,50 @@ modelBlockOpacity.set(0, { duration: 0 }); modelSplitProgress.set(0, { duration: 0 }); disconnectXOpacity.set(0, { duration: 0 }); + logoOpacity.set(1, { duration: 0 }); + titleOpacity.set(0, { duration: 0 }); + subtitleOpacity.set(0, { duration: 0 }); + chipPhase.set(0, { duration: 0 }); + deviceCountOpacity.set(0, { duration: 0 }); const t1 = setTimeout(() => { device1Opacity.set(1); }, 300); const t2 = setTimeout(() => { + titleOpacity.set(1); + }, 500); + const t3 = setTimeout(() => { + deviceCountOpacity.set(1); + }, 1200); + const t4 = setTimeout(() => { showContinueButton = true; }, 1400); return () => { clearTimeout(t1); clearTimeout(t2); + clearTimeout(t3); + clearTimeout(t4); }; } }); - // ── Step 2: "More devices, larger models" — device 1 slides left, Mac Studio enters — AUTO-ADVANCE ── + // ── Step 2: "Add devices to run larger models" — device 1 slides left, Mac Studio enters ── $effect(() => { if (onboardingStep === 2) { showContinueButton = false; - stepTitle = "More devices. Larger models."; + + // Immediately transition out step 1 elements + logoOpacity.set(0); + deviceCountOpacity.set(0); + // Smoothly crossfade the title: fade old out, update text, fade new in + titleOpacity.set(0, { duration: 300 }); + subtitleOpacity.set(0, { duration: 0 }); + + const t0 = setTimeout(() => { + stepTitle = "Add devices to run larger models"; + titleOpacity.set(1, { duration: 400 }); + }, 300); const t1 = setTimeout(() => { device1X.set(220); @@ -354,38 +404,68 @@ const t4 = setTimeout(() => { combinedLabelOpacity.set(1); }, 1600); - // Auto-advance to step 3 (no continue button needed) - const t5 = setTimeout(() => { - onboardingStep = 3; - }, 3200); + // Sequential chip reveal: first small model appears (locked)... + const t5a = setTimeout(() => { + chipPhase.set(1, { duration: 500 }); + }, 1800); + // ...first model unlocks (lights up gold) + const t5b = setTimeout(() => { + chipPhase.set(2, { duration: 400 }); + }, 2400); + // ...bigger models appear (locked) + const t5c = setTimeout(() => { + chipPhase.set(3, { duration: 500 }); + }, 2900); + // ...bigger models unlock (light up) + const t5d = setTimeout(() => { + chipPhase.set(4, { duration: 400 }); + }, 3500); + const t6 = setTimeout(() => { + showContinueButton = true; + }, 4000); return () => { + clearTimeout(t0); clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); clearTimeout(t4); - clearTimeout(t5); + clearTimeout(t5a); + clearTimeout(t5b); + clearTimeout(t5c); + clearTimeout(t5d); + clearTimeout(t6); }; } }); - // ── Step 3: "exo splits the model" — model block appears, splits — AUTO-ADVANCE ── + // ── Step 3: "exo splits the model" — model block appears, splits ── $effect(() => { if (onboardingStep === 3) { showContinueButton = false; - stepTitle = "exo splits models across devices"; + // Gently fade out the unlock chips + chipPhase.set(0, { duration: 600 }); + // Crossfade title + titleOpacity.set(0, { duration: 250 }); + subtitleOpacity.set(0, { duration: 250 }); + setTimeout(() => { + stepTitle = "exo splits models across devices"; + titleOpacity.set(1, { duration: 400 }); + subtitleOpacity.set(1, { duration: 400 }); + }, 250); + + // Wait for chips to fade before showing model block const t1 = setTimeout(() => { modelBlockOpacity.set(1); modelBlockY.set(50); - }, 300); + }, 600); const t2 = setTimeout(() => { modelSplitProgress.set(1); - }, 1200); - // Auto-advance to step 4 (no continue button) + }, 1500); const t3 = setTimeout(() => { - onboardingStep = 4; - }, 3200); + showContinueButton = true; + }, 2300); return () => { clearTimeout(t1); @@ -395,12 +475,21 @@ } }); - // ── Step 4: "A device disconnects" — connection goes red, X mark — AUTO-ADVANCE ── + // ── Step 4: "A device disconnects... exo self-heals" — full disconnect+heal sequence ── $effect(() => { if (onboardingStep === 4) { showContinueButton = false; - stepTitle = "When a device disconnects..."; + // Crossfade title + titleOpacity.set(0, { duration: 250 }); + subtitleOpacity.set(0, { duration: 250 }); + setTimeout(() => { + stepTitle = "When a device disconnects..."; + titleOpacity.set(1, { duration: 400 }); + subtitleOpacity.set(1, { duration: 400 }); + }, 250); + + // Phase 1: Disconnect const t1 = setTimeout(() => { connectionIsRed.set(1); }, 400); @@ -413,42 +502,43 @@ disconnectXOpacity.set(0); combinedLabelOpacity.set(0); }, 1600); - // Auto-advance to step 5 (no continue button) + + // Phase 2: Self-heal — crossfade title + subtitle const t4 = setTimeout(() => { - onboardingStep = 5; - }, 3000); + titleOpacity.set(0, { duration: 250 }); + subtitleOpacity.set(0, { duration: 250 }); + }, 2550); + const t4b = setTimeout(() => { + stepTitle = "exo self-heals"; + titleOpacity.set(1, { duration: 400 }); + subtitleOpacity.set(1, { duration: 400 }); + }, 2800); + const t5 = setTimeout(() => { + device1X.set(350); + device2X.set(350); + }, 3100); + const t6 = setTimeout(() => { + modelSplitProgress.set(0); + modelBlockY.set(20); // Lift up while merging + connectionIsRed.set(0); + }, 3700); + const t7 = setTimeout(() => { + modelBlockY.set(115); // Settle back down just above the device + }, 4800); + const t8 = setTimeout(() => { + advanceStep(6); + }, 6200); return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); clearTimeout(t4); - }; - } - }); - - // ── Step 5: "exo self-heals" — device 1 re-centers, model halves merge ── - $effect(() => { - if (onboardingStep === 5) { - showContinueButton = false; - stepTitle = "exo self-heals when devices fail"; - - const t1 = setTimeout(() => { - device1X.set(350); - combinedLabelOpacity.set(0); - }, 300); - const t2 = setTimeout(() => { - modelSplitProgress.set(0); - connectionIsRed.set(0); - }, 900); - const t3 = setTimeout(() => { - showContinueButton = true; - }, 2200); - - return () => { - clearTimeout(t1); - clearTimeout(t2); - clearTimeout(t3); + clearTimeout(t4b); + clearTimeout(t5); + clearTimeout(t6); + clearTimeout(t7); + clearTimeout(t8); }; } }); @@ -527,6 +617,13 @@ }, 500); } + // Auto-complete onboarding when user sends a message from step 9 + $effect(() => { + if (onboardingStep === 9 && chatStarted) { + completeOnboarding(); + } + }); + let onboardingError = $state(null); async function onboardingLaunchModel(modelId: string) { @@ -2072,6 +2169,13 @@ const nodeCount = $derived(data ? Object.keys(data.nodes).length : 0); const instanceCount = $derived(Object.keys(instanceData).length); + const suggestedPrompts = [ + "Write a poem about the ocean", + "Explain quantum computing simply", + "Help me debug my code", + "Tell me a creative story", + ]; + // Helper to get the number of nodes in a placement preview function getPreviewNodeCount(preview: PlacementPreview): number { if (!preview.memory_delta_by_node) return 0; @@ -2731,12 +2835,21 @@ {/if} {/snippet} - + { + if (!showOnboardingOverlay || stepTransitioning) return; + if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Enter') { + if (onboardingStep >= 1 && onboardingStep <= 4 && showContinueButton) { + e.preventDefault(); + advanceStep(onboardingStep < 4 ? onboardingStep + 1 : 6); + } + } + }} />
-
+
- {#if onboardingStep >= 1 && onboardingStep <= 5} - + {#if onboardingStep >= 1 && onboardingStep <= 4} +
-
- {#if onboardingStep === 1} - +
+ +
exo -

- Your home AI cluster -

-

- exo connects your devices into one powerful computer. - Run AI models that no single machine could handle alone. -

- {:else} -

- {stepTitle} -

+
+ + +

+ {onboardingStep === 1 ? 'EXO connects all your devices into an AI supercomputer.' : stepTitle} +

+ + +

{#if onboardingStep === 2} -

- Combine memory across devices to run models too large for any single machine. -

+   {:else if onboardingStep === 3} -

- The model is automatically distributed — each device handles a piece. -

+ The model is automatically distributed. Each device handles a piece. {:else if onboardingStep === 4} -

- Devices can leave anytime — laptops close, machines restart. -

- {:else if onboardingStep === 5} -

- exo automatically redistributes the model so inference continues without interruption. -

+ {stepTitle === 'exo self-heals' ? 'exo automatically redistributes the model so inference continues without interruption.' : 'Devices can leave anytime. Laptops close, machines restart.'} + {:else} +   {/if} - {/if} +

+ +

+ {Math.max(1, nodeCount)} {Math.max(1, nodeCount) === 1 ? 'Device' : 'Devices'} in your EXO network +

+ + + + + + + + + + + + + + + + + + + + + + {#if unlockedModels.length > 0 && $chipPhase > 0.01} + {@const centerX = ($device1X + $device2X) / 2} + {@const chipW = 148} + {@const chipH = 34} + {@const chipGap = 16} + {@const totalW = unlockedModels.length * chipW + (unlockedModels.length - 1) * chipGap} + {@const startX = centerX - totalW / 2} + + {@const headerProgress = Math.max(0, Math.min(1, $chipPhase * 1.5))} + {@const headerScale = 0.85 + headerProgress * 0.15} + 0.3 && headerProgress < 0.9 ? "url(#unlock-flash)" : "none"} + > + + MODELS UNLOCKED + + + + {#each unlockedModels as model, i} + {@const appearAt = i === 0 ? 0 : 2} + {@const unlockAt = i === 0 ? 1 : 3} + {@const appearProgress = Math.max(0, Math.min(1, $chipPhase - appearAt))} + {@const unlockProgress = Math.max(0, Math.min(1, $chipPhase - unlockAt))} + {@const scaleIn = 0.92 + appearProgress * 0.08} + {@const scalePop = unlockProgress > 0 && unlockProgress < 1 ? 1 + 0.08 * Math.sin(unlockProgress * Math.PI) : 1} + {@const modelName = (model.name || model.id.split('/').pop() || '').slice(0, 18)} + {@const modelSize = Math.round(getModelSizeGB(model))} + {#if appearProgress > 0} + {@const settled = unlockProgress >= 1} + {@const flashing = unlockProgress > 0 && unlockProgress < 1} + + + {#if flashing} + {@const burstScale = 1 + unlockProgress * 0.5} + {@const burstOpacity = Math.sin(unlockProgress * Math.PI) * 0.6} + + {/if} + + + + + {modelName} + + + {#if modelSize} + + {modelSize} GB + + {/if} + + {/if} + {/each} + {/if} + {#if $modelBlockOpacity > 0.01} {#if $modelSplitProgress < 0.05} @@ -3060,13 +3292,13 @@
@@ -3266,65 +3498,74 @@

Almost ready...

{:else if onboardingStep === 9} - +
exo -

- You're all set -

- {#if onboardingModelId} -

- {onboardingModelId.split("/").pop() ?? onboardingModelId} is ready to use. +

+ {onboardingModelId.split("/").pop() ?? onboardingModelId}

{/if} - +
+ +
+ +
+ {#each suggestedPrompts as chip} + + {/each} +
{/if} - - + +
+ + +
@@ -3366,9 +3607,9 @@ {#if !topologyOnlyEnabled}
- {#if !topologyOnlyEnabled && sidebarVisible && chatStarted} + {#if !topologyOnlyEnabled && sidebarVisible}