style: polish onboarding popout and dashboard UI

FirstLaunchPopout: rename to WelcomeCalloutView, use transparent panel
with ultraThinMaterial instead of HUD style, add fade in/out animations,
friendlier copy ("Welcome to EXO!", "Run your first model here:"),
smaller 280×100 window, dismiss on Open Dashboard click.

Dashboard onboarding: switch body text from font-mono to font-sans,
rounded-full buttons with softer hover states, subtler secondary text
opacity, cleaner copy ("Your devices" vs "Here are your devices"),
rounded-xl model cards, refined spacing throughout.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Alex Cheema
2026-02-16 06:21:16 -08:00
co-authored by Claude Opus 4.6
parent f81488cfc0
commit f0c11bc63b
2 changed files with 116 additions and 70 deletions
+64 -33
View File
@@ -1,9 +1,8 @@
import AppKit
import SwiftUI
/// A small floating panel that appears near the menu bar on first launch,
/// showing a countdown before auto-opening the dashboard.
/// Inspired by LlamaBarn's menu bar popout pattern.
/// A small floating callout that drops down from the menu bar area on first launch,
/// pointing the user to the web dashboard. Clean, minimal, speech-bubble style.
@MainActor
final class FirstLaunchPopout {
private var panel: NSPanel?
@@ -14,18 +13,19 @@ final class FirstLaunchPopout {
guard panel == nil else { return }
let hostingView = NSHostingView(
rootView: PopoutContentView(
rootView: WelcomeCalloutView(
onDismiss: { [weak self] in
self?.dismiss()
},
onOpen: { [weak self] in
self?.openDashboard()
self?.dismiss()
}))
hostingView.frame = NSRect(x: 0, y: 0, width: 300, height: 120)
hostingView.frame = NSRect(x: 0, y: 0, width: 280, height: 100)
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 300, height: 120),
styleMask: [.nonactivatingPanel, .hudWindow, .utilityWindow],
contentRect: NSRect(x: 0, y: 0, width: 280, height: 100),
styleMask: [.nonactivatingPanel, .fullSizeContentView],
backing: .buffered,
defer: false
)
@@ -38,8 +38,10 @@ final class FirstLaunchPopout {
window.isMovableByWindowBackground = false
window.hidesOnDeactivate = false
window.collectionBehavior = [.canJoinAllSpaces, .stationary]
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
// Position near top-right of screen (near menu bar area)
// Position near top-right, just below the menu bar
if let screen = NSScreen.main {
let screenFrame = screen.visibleFrame
let x = screenFrame.maxX - window.frame.width - 16
@@ -47,16 +49,23 @@ final class FirstLaunchPopout {
window.setFrameOrigin(NSPoint(x: x, y: y))
}
window.alphaValue = 0
window.orderFrontRegardless()
panel = window
// Start countdown: auto-open dashboard after 5 seconds, then dismiss
// Fade in
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.3
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().alphaValue = 1
}
// Auto-open dashboard after 5s then dismiss
countdownTask = Task {
try? await Task.sleep(nanoseconds: 5_000_000_000)
if !Task.isCancelled {
openDashboard()
// Give the browser a moment, then dismiss
try? await Task.sleep(nanoseconds: 1_000_000_000)
try? await Task.sleep(nanoseconds: 800_000_000)
if !Task.isCancelled {
dismiss()
}
@@ -67,7 +76,16 @@ final class FirstLaunchPopout {
func dismiss() {
countdownTask?.cancel()
countdownTask = nil
panel?.close()
guard let window = panel else { return }
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.2
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
window.animator().alphaValue = 0
}, completionHandler: {
Task { @MainActor in
window.close()
}
})
panel = nil
}
@@ -77,58 +95,71 @@ final class FirstLaunchPopout {
}
}
/// SwiftUI content for the first-launch popout
private struct PopoutContentView: View {
/// Minimal welcome callout friendly pointer, not a wall of text.
private struct WelcomeCalloutView: View {
let onDismiss: () -> Void
let onOpen: () -> Void
@State private var countdown = 5
@State private var timerTask: Task<Void, Never>?
@State private var appeared = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.imageScale(.large)
Text("EXO is ready!")
.font(.system(.headline, design: .default))
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
Text("Welcome to EXO!")
.font(.system(.headline, design: .rounded))
.fontWeight(.semibold)
.foregroundColor(.primary)
Spacer()
Button {
onDismiss()
} label: {
Image(systemName: "xmark")
.imageScale(.small)
.foregroundColor(.secondary)
Image(systemName: "xmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
}
Text("http://localhost:52415")
.font(.system(.caption, design: .monospaced))
Text("Run your first model here:")
.font(.system(.subheadline, design: .default))
.foregroundColor(.secondary)
HStack {
Button {
onOpen()
onDismiss()
} label: {
Text("Open Dashboard")
.font(.caption)
Label("Open Dashboard", systemImage: "arrow.up.right.square")
.font(.system(.caption, design: .default))
.fontWeight(.medium)
}
.buttonStyle(.borderedProminent)
.tint(.accentColor)
.controlSize(.small)
Spacer()
Text("Opening in \(countdown)s")
.font(.caption2)
.foregroundColor(.secondary)
if countdown > 0 {
Text("Opening in \(countdown)s")
.font(.system(.caption2, design: .default))
.foregroundColor(.secondary.opacity(0.6))
.monospacedDigit()
}
}
}
.padding(12)
.padding(14)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.ultraThinMaterial)
.shadow(color: .black.opacity(0.15), radius: 12, y: 4)
}
.padding(4)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : -8)
.onAppear {
withAnimation(.easeOut(duration: 0.35).delay(0.05)) {
appeared = true
}
startCountdown()
}
.onDisappear {
+52 -37
View File
@@ -2199,18 +2199,19 @@
>
exo
</div>
<h1 class="text-2xl font-mono text-white/90 mb-3">
<h1
class="text-2xl font-sans font-light text-white/90 mb-3 tracking-wide"
>
Welcome to exo
</h1>
<p class="text-base text-white/60 leading-relaxed">
Run AI models locally, across all your devices. Let's get you set
up.
<p class="text-base font-sans text-white/50 leading-relaxed">
Run AI models locally, across all your devices.
</p>
</div>
<button
type="button"
onclick={() => (onboardingStep = 2)}
class="inline-flex items-center gap-2 px-8 py-3.5 bg-exo-yellow text-exo-black font-mono text-sm font-bold tracking-wider uppercase rounded-lg hover:bg-exo-yellow-darker hover:shadow-[0_0_30px_rgba(255,215,0,0.3)] transition-all duration-200 cursor-pointer"
class="inline-flex items-center gap-2 px-8 py-3 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"
>
Get Started
<svg
@@ -2236,10 +2237,12 @@
out:fade={{ duration: 200 }}
>
<div class="text-center mb-6">
<h1 class="text-2xl font-mono text-white/90 mb-2">
Here are your devices
<h1
class="text-2xl font-sans font-light text-white/90 mb-2 tracking-wide"
>
Your devices
</h1>
<p class="text-sm text-white/60">
<p class="text-sm font-sans text-white/50">
{nodeCount} device{nodeCount !== 1 ? "s" : ""} connected
{#if clusterTotalMemoryGB() > 0}
· {clusterTotalMemoryGB().toFixed(0)} GB total memory
@@ -2256,15 +2259,16 @@
onNodeClick={togglePreviewNodeFilter}
/>
</div>
<p class="text-sm text-white/50 mt-2 max-w-md text-center">
Running on {nodeCount} device{nodeCount !== 1 ? "s" : ""}. Install
exo on more devices on your network to combine their power they
connect automatically.
<p
class="text-sm font-sans text-white/40 mt-2 max-w-md text-center leading-relaxed"
>
Install exo on more devices on your network to combine their power
they connect automatically.
</p>
<button
type="button"
onclick={() => (onboardingStep = 3)}
class="inline-flex items-center gap-2 px-8 py-3.5 mt-6 bg-exo-yellow text-exo-black font-mono text-sm font-bold tracking-wider uppercase rounded-lg hover:bg-exo-yellow-darker hover:shadow-[0_0_30px_rgba(255,215,0,0.3)] transition-all duration-200 cursor-pointer"
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
<svg
@@ -2290,11 +2294,13 @@
out:fade={{ duration: 200 }}
>
<div class="text-center mb-8">
<h1 class="text-2xl font-mono text-white/90 mb-2">
<h1
class="text-2xl font-sans font-light text-white/90 mb-2 tracking-wide"
>
Choose a model
</h1>
<p class="text-sm text-white/60">
Pick a model to download and run on your cluster.
<p class="text-sm font-sans text-white/50">
Pick a model to download and run locally.
</p>
</div>
@@ -2309,7 +2315,7 @@
{#if onboardingModels.length === 0}
<div class="text-center py-8">
<div class="text-sm text-white/50 font-mono animate-pulse">
<div class="text-sm text-white/40 font-sans animate-pulse">
Loading models...
</div>
</div>
@@ -2322,28 +2328,29 @@
<button
type="button"
onclick={() => onboardingLaunchModel(model.id)}
class="w-full flex items-center justify-between gap-4 px-5 py-4 rounded-lg border transition-all duration-200 cursor-pointer {fitsNow
? 'border-white/10 bg-white/[0.03] hover:border-exo-yellow/40 hover:bg-white/[0.06]'
: 'border-white/5 bg-white/[0.01] hover:border-white/15 opacity-70'}"
class="w-full flex items-center justify-between gap-4 px-5 py-4 rounded-xl border transition-all duration-200 cursor-pointer {fitsNow
? 'border-white/8 bg-white/[0.03] hover:border-exo-yellow/30 hover:bg-white/[0.05]'
: 'border-white/5 bg-white/[0.01] hover:border-white/10 opacity-60'}"
>
<div class="flex flex-col items-start gap-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-mono text-white/90 truncate"
<span
class="text-sm font-sans font-medium text-white/90 truncate"
>{model.name || model.id}</span
>
{#each tags as tag}
<span
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-exo-yellow/15 text-exo-yellow tracking-wider"
class="text-[10px] font-sans font-medium px-1.5 py-0.5 rounded-full bg-exo-yellow/10 text-exo-yellow/80"
>{tag}</span
>
{/each}
</div>
<span class="text-xs font-mono text-white/40 truncate"
<span class="text-xs font-mono text-white/30 truncate"
>{model.id}</span
>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<span class="text-xs font-mono text-white/50"
<span class="text-xs font-mono text-white/40"
>{sizeGB >= 1 ? sizeGB.toFixed(0) : sizeGB.toFixed(1)} GB</span
>
<svg
@@ -2370,7 +2377,7 @@
onclick={() => {
isModelPickerOpen = true;
}}
class="text-sm font-mono text-white/50 hover:text-exo-yellow transition-colors cursor-pointer underline underline-offset-4 decoration-white/20 hover:decoration-exo-yellow/40"
class="text-sm font-sans text-white/40 hover:text-exo-yellow/80 transition-colors cursor-pointer underline underline-offset-4 decoration-white/15 hover:decoration-exo-yellow/30"
>
Browse all models
</button>
@@ -2383,7 +2390,11 @@
out:fade={{ duration: 200 }}
>
<div class="mb-8">
<h1 class="text-2xl font-mono text-white/90 mb-2">Downloading</h1>
<h1
class="text-2xl font-sans font-light text-white/90 mb-2 tracking-wide"
>
Downloading
</h1>
<p class="text-sm text-white/60">
{#if onboardingModelId}
<span class="text-exo-yellow">{onboardingModelId}</span>
@@ -2424,7 +2435,7 @@
</div>
{/if}
<p class="text-xs text-white/30 mt-8">
<p class="text-xs font-sans text-white/30 mt-8">
This may take a few minutes depending on your connection.
</p>
</div>
@@ -2436,7 +2447,9 @@
out:fade={{ duration: 200 }}
>
<div class="mb-8">
<h1 class="text-2xl font-mono text-white/90 mb-2">
<h1
class="text-2xl font-sans font-light text-white/90 mb-2 tracking-wide"
>
Loading into memory
</h1>
<p class="text-sm text-white/60">
@@ -2452,7 +2465,7 @@
></div>
</div>
<p class="text-sm text-white/40 font-mono">Almost ready...</p>
<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 -->
@@ -2463,14 +2476,14 @@
>
<!-- Subtle branding -->
<div
class="text-2xl font-mono text-white/20 font-bold tracking-wider mb-8"
class="text-2xl font-mono text-white/15 font-bold tracking-wider mb-8"
>
exo
</div>
<!-- Model name -->
{#if onboardingModelId}
<p class="text-sm text-white/40 font-mono mb-4">
<p class="text-sm text-white/35 font-sans mb-4">
{onboardingModelId.split("/").pop() ?? onboardingModelId}
</p>
{/if}
@@ -2489,7 +2502,7 @@
</div>
<!-- Suggestion chips -->
<div class="flex flex-wrap justify-center gap-3 mt-6">
<div class="flex flex-wrap justify-center gap-2.5 mt-8">
{#each ["Write a poem about the ocean", "Explain quantum computing simply", "Help me debug my code", "Tell me a creative story"] as chip}
<button
type="button"
@@ -2497,7 +2510,7 @@
sendMessage(chip);
completeOnboarding();
}}
class="px-4 py-2 rounded-full border border-white/10 bg-white/5 text-sm text-white/60 hover:bg-white/10 hover:text-white/80 hover:border-white/20 transition-all duration-200 cursor-pointer"
class="px-4 py-2 rounded-full border border-white/8 bg-white/[0.03] text-[13px] font-sans text-white/50 hover:bg-white/[0.07] hover:text-white/70 hover:border-white/15 transition-all duration-200 cursor-pointer"
>
{chip}
</button>
@@ -2676,13 +2689,15 @@
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div class="text-center pointer-events-auto max-w-md px-6">
<div class="mb-4">
<div class="mb-5">
<div
class="text-2xl font-mono text-exo-yellow font-bold tracking-wide mb-2"
>
Welcome to exo
exo
</div>
<p class="text-sm text-white/70 leading-relaxed">
<p
class="text-sm font-sans text-white/50 leading-relaxed"
>
Your devices are connected. Choose a model to start
running AI locally.
</p>
@@ -2690,7 +2705,7 @@
<button
type="button"
onclick={() => (isModelPickerOpen = true)}
class="inline-flex items-center gap-2 px-6 py-3 bg-exo-yellow text-exo-black font-mono text-sm font-bold tracking-wider uppercase rounded-lg hover:bg-exo-yellow-darker hover:shadow-[0_0_30px_rgba(255,215,0,0.3)] transition-all duration-200 cursor-pointer"
class="inline-flex items-center gap-2 px-6 py-3 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"
>
<svg
class="w-5 h-5"