feat: revert to light background, fix DMG layout arrow

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 47e3714e31
commit 312b7a7580
3 changed files with 149 additions and 90 deletions
+75 -73
View File
@@ -2597,31 +2597,33 @@
class="relative h-screen w-full flex flex-col bg-exo-dark-gray overflow-hidden"
>
<!-- Scanline overlay -->
<div
class="fixed inset-0 pointer-events-none z-50 scanlines opacity-20"
></div>
{#if !showOnboarding}
<div
class="fixed inset-0 pointer-events-none z-50 scanlines opacity-20"
></div>
<!-- Shooting Stars Background - one every ~15s -->
<div class="shooting-stars">
<div
class="shooting-star"
style="top: 10%; left: 20%; --duration: 45s; --delay: 0s;"
></div>
<div
class="shooting-star"
style="top: 30%; left: 65%; --duration: 45s; --delay: 15s;"
></div>
<div
class="shooting-star"
style="top: 50%; left: 40%; --duration: 45s; --delay: 30s;"
></div>
</div>
<!-- Shooting Stars Background - one every ~15s -->
<div class="shooting-stars">
<div
class="shooting-star"
style="top: 10%; left: 20%; --duration: 45s; --delay: 0s;"
></div>
<div
class="shooting-star"
style="top: 30%; left: 65%; --duration: 45s; --delay: 15s;"
></div>
<div
class="shooting-star"
style="top: 50%; left: 40%; --duration: 45s; --delay: 30s;"
></div>
</div>
{/if}
{#if showOnboarding}
<!-- ═══════════════════════════════════════════════════════ -->
<!-- FULL-SCREEN ONBOARDING WIZARD -->
<!-- ═══════════════════════════════════════════════════════ -->
<div class="flex-1 flex items-center justify-center relative z-10">
<div class="flex-1 flex items-center justify-center relative z-10 bg-white">
{#if onboardingStep === 1}
<!-- Step 1: Welcome -->
<div
@@ -2636,11 +2638,11 @@
exo
</div>
<h1
class="text-2xl font-sans font-light text-white/90 mb-3 tracking-wide"
class="text-2xl font-sans font-light text-gray-900 mb-3 tracking-wide"
>
Welcome to exo
</h1>
<p class="text-base font-sans text-white/50 leading-relaxed">
<p class="text-base font-sans text-gray-500 leading-relaxed">
Run AI models locally, across all your devices.
</p>
</div>
@@ -2674,7 +2676,7 @@
>
<div class="text-center mb-4">
<h1
class="text-2xl font-sans font-light text-white/90 mb-2 tracking-wide"
class="text-2xl font-sans font-light text-gray-900 mb-2 tracking-wide"
>
Add more devices, run bigger models
</h1>
@@ -2791,7 +2793,7 @@
x="42"
y="92"
text-anchor="middle"
fill="rgba(255,255,255,0.7)"
fill="rgba(0,0,0,0.7)"
style="font-size: 13px; font-family: system-ui, sans-serif;"
>
MacBook Pro
@@ -2800,7 +2802,7 @@
x="42"
y="108"
text-anchor="middle"
fill="rgba(255,255,255,0.35)"
fill="rgba(0,0,0,0.35)"
style="font-size: 11px; font-family: 'SF Mono', monospace;"
>
36 GB
@@ -2862,7 +2864,7 @@
x="39"
y="86"
text-anchor="middle"
fill="rgba(255,255,255,0.7)"
fill="rgba(0,0,0,0.7)"
style="font-size: 13px; font-family: system-ui, sans-serif;"
>
Mac Studio
@@ -2871,7 +2873,7 @@
x="39"
y="102"
text-anchor="middle"
fill="rgba(255,255,255,0.35)"
fill="rgba(0,0,0,0.35)"
style="font-size: 11px; font-family: 'SF Mono', monospace;"
>
192 GB
@@ -2939,15 +2941,15 @@
width="110"
height="48"
rx="8"
fill="rgba(255,255,255,0.03)"
stroke="rgba(255,255,255,0.12)"
fill="rgba(0,0,0,0.04)"
stroke="rgba(0,0,0,0.1)"
stroke-width="1"
/>
<text
x="55"
y="20"
text-anchor="middle"
fill="rgba(255,255,255,0.7)"
fill="rgba(0,0,0,0.7)"
style="font-size: 13px; font-family: system-ui, sans-serif;"
>
Qwen3 8B
@@ -2956,7 +2958,7 @@
x="55"
y="37"
text-anchor="middle"
fill="rgba(255,255,255,0.35)"
fill="rgba(0,0,0,0.35)"
style="font-size: 11px; font-family: 'SF Mono', monospace;"
>
4 GB
@@ -2978,10 +2980,10 @@
rx="8"
fill={deviceAnimPhase >= 3
? "rgba(255,215,0,0.06)"
: "rgba(255,255,255,0.015)"}
: "rgba(0,0,0,0.03)"}
stroke={deviceAnimPhase >= 3
? "rgba(255,215,0,0.35)"
: "rgba(255,255,255,0.06)"}
: "rgba(0,0,0,0.08)"}
stroke-width="1"
filter={deviceAnimPhase >= 3
? "url(#onb-gold-glow)"
@@ -2997,13 +2999,13 @@
height="11"
rx="2"
fill="none"
stroke="rgba(255,255,255,0.2)"
stroke="rgba(0,0,0,0.2)"
stroke-width="1"
/>
<path
d="M 2 9 V 6 C 2 3 4.5 1 7 1 C 9.5 1 12 3 12 6 V 9"
fill="none"
stroke="rgba(255,255,255,0.2)"
stroke="rgba(0,0,0,0.2)"
stroke-width="1"
/>
</g>
@@ -3044,10 +3046,10 @@
rx="8"
fill={deviceAnimPhase >= 4
? "rgba(255,215,0,0.06)"
: "rgba(255,255,255,0.015)"}
: "rgba(0,0,0,0.03)"}
stroke={deviceAnimPhase >= 4
? "rgba(255,215,0,0.35)"
: "rgba(255,255,255,0.06)"}
: "rgba(0,0,0,0.08)"}
stroke-width="1"
filter={deviceAnimPhase >= 4
? "url(#onb-gold-glow)"
@@ -3063,13 +3065,13 @@
height="11"
rx="2"
fill="none"
stroke="rgba(255,255,255,0.2)"
stroke="rgba(0,0,0,0.2)"
stroke-width="1"
/>
<path
d="M 2 9 V 6 C 2 3 4.5 1 7 1 C 9.5 1 12 3 12 6 V 9"
fill="none"
stroke="rgba(255,255,255,0.2)"
stroke="rgba(0,0,0,0.2)"
stroke-width="1"
/>
</g>
@@ -3110,10 +3112,10 @@
rx="8"
fill={deviceAnimPhase >= 4
? "rgba(255,215,0,0.08)"
: "rgba(255,255,255,0.015)"}
: "rgba(0,0,0,0.03)"}
stroke={deviceAnimPhase >= 4
? "rgba(255,215,0,0.45)"
: "rgba(255,255,255,0.06)"}
: "rgba(0,0,0,0.08)"}
stroke-width={deviceAnimPhase >= 4 ? "1.5" : "1"}
filter={deviceAnimPhase >= 4
? "url(#onb-gold-glow)"
@@ -3129,13 +3131,13 @@
height="11"
rx="2"
fill="none"
stroke="rgba(255,255,255,0.2)"
stroke="rgba(0,0,0,0.2)"
stroke-width="1"
/>
<path
d="M 2 9 V 6 C 2 3 4.5 1 7 1 C 9.5 1 12 3 12 6 V 9"
fill="none"
stroke="rgba(255,255,255,0.2)"
stroke="rgba(0,0,0,0.2)"
stroke-width="1"
/>
</g>
@@ -3168,7 +3170,7 @@
x="300"
y="72"
text-anchor="middle"
fill="rgba(255,255,255,0.25)"
fill="rgba(0,0,0,0.3)"
style="font-size: 11px; font-family: system-ui, sans-serif; text-transform: uppercase; letter-spacing: 0.12em;"
in:fade={{ duration: 400 }}
>
@@ -3213,11 +3215,11 @@
>
<div class="text-center mb-6">
<h1
class="text-2xl font-sans font-light text-white/90 mb-2 tracking-wide"
class="text-2xl font-sans font-light text-gray-900 mb-2 tracking-wide"
>
Your devices
</h1>
<p class="text-sm font-sans text-white/50">
<p class="text-sm font-sans text-gray-500">
{nodeCount} device{nodeCount !== 1 ? "s" : ""} connected
{#if clusterTotalMemoryGB() > 0}
· {clusterTotalMemoryGB().toFixed(0)} GB total memory
@@ -3225,7 +3227,7 @@
</p>
</div>
<div
class="w-full h-80 bg-exo-dark-gray/40 rounded-lg overflow-hidden border border-white/5 mb-8"
class="w-full h-80 bg-exo-dark-gray rounded-lg overflow-hidden border border-gray-200 mb-8"
>
<TopologyGraph
class="w-full h-full"
@@ -3235,7 +3237,7 @@
/>
</div>
<p
class="text-sm font-sans text-white/40 mt-2 max-w-md text-center leading-relaxed"
class="text-sm font-sans text-gray-400 mt-2 max-w-md text-center leading-relaxed"
>
Install exo on more devices on your network to combine their power —
they connect automatically.
@@ -3270,18 +3272,18 @@
>
<div class="text-center mb-8">
<h1
class="text-2xl font-sans font-light text-white/90 mb-2 tracking-wide"
class="text-2xl font-sans font-light text-gray-900 mb-2 tracking-wide"
>
Choose a model
</h1>
<p class="text-sm font-sans text-white/50">
<p class="text-sm font-sans text-gray-500">
Pick a model to download and run locally.
</p>
</div>
{#if onboardingError}
<div
class="w-full mb-6 px-4 py-3 rounded-lg border border-red-500/30 bg-red-500/10 text-sm font-mono text-red-300"
class="w-full mb-6 px-4 py-3 rounded-lg border border-red-200 bg-red-50 text-sm font-mono text-red-600"
in:fade={{ duration: 200 }}
>
{onboardingError}
@@ -3290,7 +3292,7 @@
{#if onboardingModels.length === 0}
<div class="text-center py-8">
<div class="text-sm text-white/40 font-sans animate-pulse">
<div class="text-sm text-gray-400 font-sans animate-pulse">
Loading models...
</div>
</div>
@@ -3304,13 +3306,13 @@
type="button"
onclick={() => onboardingLaunchModel(model.id)}
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'}"
? 'border-gray-200 bg-gray-50 hover:border-exo-yellow/50 hover:bg-yellow-50/50'
: 'border-gray-200 bg-gray-50/50 hover:border-gray-300 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-sans font-medium text-white/90 truncate"
class="text-sm font-sans font-medium text-gray-900 truncate"
>{model.name || model.id}</span
>
{#each tags as tag}
@@ -3320,16 +3322,16 @@
>
{/each}
</div>
<span class="text-xs font-mono text-white/30 truncate"
<span class="text-xs font-mono text-gray-400 truncate"
>{model.id}</span
>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<span class="text-xs font-mono text-white/40"
<span class="text-xs font-mono text-gray-500"
>{sizeGB >= 1 ? sizeGB.toFixed(0) : sizeGB.toFixed(1)} GB</span
>
<svg
class="w-4 h-4 text-white/30"
class="w-4 h-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -3352,7 +3354,7 @@
onclick={() => {
isModelPickerOpen = true;
}}
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"
class="text-sm font-sans text-gray-400 hover:text-exo-yellow transition-colors cursor-pointer underline underline-offset-4 decoration-gray-300 hover:decoration-exo-yellow/30"
>
Browse all models
</button>
@@ -3366,11 +3368,11 @@
>
<div class="mb-8">
<h1
class="text-2xl font-sans font-light text-white/90 mb-2 tracking-wide"
class="text-2xl font-sans font-light text-gray-900 mb-2 tracking-wide"
>
Downloading
</h1>
<p class="text-sm text-white/60">
<p class="text-sm text-gray-500">
{#if onboardingModelId}
<span class="text-exo-yellow">{onboardingModelId}</span>
{/if}
@@ -3379,38 +3381,38 @@
{#if onboardingDownloadProgress}
<div class="w-full max-w-md mx-auto space-y-4">
<div class="relative h-2 bg-white/5 rounded-full overflow-hidden">
<div class="relative h-2 bg-gray-200 rounded-full overflow-hidden">
<div
class="absolute inset-y-0 left-0 bg-gradient-to-r from-exo-yellow to-amber-400 rounded-full transition-all duration-500"
style="width: {onboardingDownloadProgress.percentage}%"
></div>
</div>
<div class="flex justify-between text-xs font-mono text-white/50">
<div class="flex justify-between text-xs font-mono text-gray-500">
<span>{onboardingDownloadProgress.percentage.toFixed(1)}%</span>
<span
>{formatBytes(onboardingDownloadProgress.downloadedBytes)} /
{formatBytes(onboardingDownloadProgress.totalBytes)}</span
>
</div>
<div class="flex justify-between text-xs font-mono text-white/40">
<div class="flex justify-between text-xs font-mono text-gray-400">
<span>{formatSpeed(onboardingDownloadProgress.speed)}</span>
<span>ETA: {formatEta(onboardingDownloadProgress.etaMs)}</span>
</div>
</div>
{:else}
<div class="w-full max-w-md mx-auto">
<div class="relative h-2 bg-white/5 rounded-full overflow-hidden">
<div class="relative h-2 bg-gray-200 rounded-full overflow-hidden">
<div
class="absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-exo-yellow to-amber-400 rounded-full animate-pulse"
></div>
</div>
<p class="text-xs font-mono text-white/40 mt-4">
<p class="text-xs font-mono text-gray-400 mt-4">
Preparing download...
</p>
</div>
{/if}
<p class="text-xs font-sans text-white/30 mt-8">
<p class="text-xs font-sans text-gray-300 mt-8">
This may take a few minutes depending on your connection.
</p>
</div>
@@ -3423,11 +3425,11 @@
>
<div class="mb-8">
<h1
class="text-2xl font-sans font-light text-white/90 mb-2 tracking-wide"
class="text-2xl font-sans font-light text-gray-900 mb-2 tracking-wide"
>
Loading into memory
</h1>
<p class="text-sm text-white/60">
<p class="text-sm text-gray-500">
{#if onboardingModelId}
<span class="text-exo-yellow">{onboardingModelId}</span>
{/if}
@@ -3440,7 +3442,7 @@
></div>
</div>
<p class="text-sm text-white/40 font-sans">Almost ready...</p>
<p class="text-sm text-gray-400 font-sans">Almost ready...</p>
</div>
{:else if onboardingStep === 7}
<!-- Step 7: Chat — centered input auto-appears, first message transitions to dashboard -->
@@ -3451,20 +3453,20 @@
>
<!-- Subtle branding -->
<div
class="text-2xl font-mono text-white/15 font-bold tracking-wider mb-8"
class="text-2xl font-mono text-gray-200 font-bold tracking-wider mb-8"
>
exo
</div>
<!-- Model name -->
{#if onboardingModelId}
<p class="text-sm text-white/35 font-sans mb-4">
<p class="text-sm text-gray-400 font-sans mb-4">
{onboardingModelId.split("/").pop() ?? onboardingModelId}
</p>
{/if}
<!-- Centered ChatForm — first message completes onboarding -->
<div class="w-full">
<div class="w-full bg-exo-dark-gray rounded-xl p-4">
<ChatForm
placeholder="Ask anything"
autofocus={true}
@@ -3485,7 +3487,7 @@
sendMessage(chip);
completeOnboarding();
}}
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"
class="px-4 py-2 rounded-full border border-gray-200 bg-gray-50 text-[13px] font-sans text-gray-500 hover:bg-gray-100 hover:text-gray-700 hover:border-gray-300 transition-all duration-200 cursor-pointer"
>
{chip}
</button>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

+74 -17
View File
@@ -1,34 +1,91 @@
#!/usr/bin/env python3
"""Copy the static DMG background image to the specified output path.
"""Generate the DMG background image with a centered drag-to-Applications arrow.
The background is a 1600×740 retina PNG (2× for 800×400 logical window) with a
hand-drawn arrow and yellow bookmark accents on a white background. Based on
Ollama's DMG background (MIT-licensed).
The output is a 1600×740 retina PNG (2× for 800×400 logical window).
Icons are positioned at (200, 190) and (600, 190) in logical coordinates;
the arrow is drawn centered between them.
Usage:
python3 generate-background.py output.png
python3 generate-background.py [output.png]
If no output path is given, overwrites the bundled background.png in-place.
"""
from __future__ import annotations
import shutil
import math
import sys
from pathlib import Path
BACKGROUND_PNG = Path(__file__).parent / "background.png"
from PIL import Image, ImageDraw
# Retina dimensions (2× logical 800×400)
WIDTH = 1600
HEIGHT = 740
# Icon positions in logical coords → retina coords
# App icon at (200, 190), Applications at (600, 190)
APP_X = 200 * 2 # 400
APPS_X = 600 * 2 # 1200
ICON_Y = 190 * 2 # 380
# Arrow drawn between icons, slightly above icon center
ARROW_START_X = APP_X + 160 # past the icon
ARROW_END_X = APPS_X - 160 # before the Applications icon
ARROW_Y = ICON_Y # same height as icons
ARROW_RISE = 120 # upward arc height
def draw_arrow(draw: ImageDraw.ImageDraw) -> None:
"""Draw a hand-drawn-style curved arrow from app icon toward Applications."""
color = (30, 30, 30)
line_width = 8
# Compute bezier curve points for a gentle upward arc
points: list[tuple[float, float]] = []
steps = 80
for i in range(steps + 1):
t = i / steps
# Quadratic bezier: start → control → end
cx = (ARROW_START_X + ARROW_END_X) / 2
cy = ARROW_Y - ARROW_RISE
x = (1 - t) ** 2 * ARROW_START_X + 2 * (1 - t) * t * cx + t**2 * ARROW_END_X
y = (1 - t) ** 2 * ARROW_Y + 2 * (1 - t) * t * cy + t**2 * ARROW_Y
points.append((x, y))
# Draw the curve as connected line segments
for i in range(len(points) - 1):
draw.line([points[i], points[i + 1]], fill=color, width=line_width)
# Arrowhead at the end
end_x, end_y = points[-1]
# Direction from second-to-last to last point
prev_x, prev_y = points[-3]
angle = math.atan2(end_y - prev_y, end_x - prev_x)
head_len = 36
head_angle = math.radians(25)
left_x = end_x - head_len * math.cos(angle - head_angle)
left_y = end_y - head_len * math.sin(angle - head_angle)
right_x = end_x - head_len * math.cos(angle + head_angle)
right_y = end_y - head_len * math.sin(angle + head_angle)
draw.polygon(
[(end_x, end_y), (left_x, left_y), (right_x, right_y)],
fill=color,
)
def generate_background(output_path: str) -> None:
"""Copy the static background image to the output path."""
if not BACKGROUND_PNG.exists():
print(f"Error: {BACKGROUND_PNG} not found", file=sys.stderr)
sys.exit(1)
shutil.copy2(BACKGROUND_PNG, output_path)
"""Generate a white DMG background with a centered arrow."""
img = Image.new("RGBA", (WIDTH, HEIGHT), (255, 255, 255, 255))
draw = ImageDraw.Draw(img)
draw_arrow(draw)
img.save(output_path, "PNG")
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <output.png>", file=sys.stderr)
sys.exit(1)
generate_background(sys.argv[1])
print(f"Background image written to {sys.argv[1]}")
default_output = str(Path(__file__).parent / "background.png")
out = sys.argv[1] if len(sys.argv) >= 2 else default_output
generate_background(out)
print(f"Background image written to {out}")