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:
co-authored by
Claude Opus 4.6
parent
f81488cfc0
commit
f0c11bc63b
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user