feat: complete onboarding rewrite, native settings, DMG polish, menu bar UX

- Full-screen Apple-style onboarding wizard (Welcome → Devices → Pick Model
  → Downloading → Loading → Ready) replacing the broken layered approach
- Native macOS Settings window (General/Model/About tabs) replacing the
  cramped Advanced dropdown section
- First-launch floating popout with 5s countdown that auto-opens dashboard
- Clean DMG installer: dark gradient, white anti-aliased arrow, no grid
- Menu bar: "Web Dashboard" with link icon, Base URL copy (localhost:52415/v1)
- UI renames: Sharding Strategy, Interconnect, Load Model
- Helpful "no model loaded" message instead of raw error on chat submit
- Reduce launch delay from 15s to 5s

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 51b2506711
commit 01400485bf
9 changed files with 2098 additions and 1234 deletions
+63 -103
View File
@@ -15,18 +15,16 @@ struct ContentView: View {
@EnvironmentObject private var localNetworkChecker: LocalNetworkChecker
@EnvironmentObject private var updater: SparkleUpdater
@EnvironmentObject private var thunderboltBridgeService: ThunderboltBridgeService
@EnvironmentObject private var settingsWindowController: SettingsWindowController
@State private var focusedNode: NodeViewModel?
@State private var deletingInstanceIDs: Set<String> = []
@State private var showAllNodes = false
@State private var showAllInstances = false
@State private var showAdvanced = false
@State private var showDebugInfo = false
@State private var bugReportInFlight = false
@State private var bugReportMessage: String?
@State private var uninstallInProgress = false
@State private var pendingNamespace: String = ""
@State private var pendingHFToken: String = ""
@State private var pendingEnableImageModels = false
@State private var baseURLCopied = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -275,93 +273,23 @@ struct ContentView: View {
private var advancedSection: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Advanced")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
collapseButton(isExpanded: $showAdvanced)
HoverButton(
title: "Settings",
tint: .primary,
trailingSystemImage: "gear",
small: false
) {
settingsWindowController.open(controller: controller, updater: updater)
}
.animation(nil, value: showAdvanced)
if showAdvanced {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
Text("Cluster Namespace")
.font(.caption2)
.foregroundColor(.secondary)
HStack {
TextField("optional", text: $pendingNamespace)
.textFieldStyle(.roundedBorder)
.font(.caption2)
.onAppear {
pendingNamespace = controller.customNamespace
}
Button("Save & Restart") {
controller.customNamespace = pendingNamespace
if controller.status == .running || controller.status == .starting {
controller.restart()
}
}
.font(.caption2)
.disabled(pendingNamespace == controller.customNamespace)
}
}
VStack(alignment: .leading, spacing: 4) {
Text("HuggingFace Token")
.font(.caption2)
.foregroundColor(.secondary)
HStack {
SecureField("optional", text: $pendingHFToken)
.textFieldStyle(.roundedBorder)
.font(.caption2)
.onAppear {
pendingHFToken = controller.hfToken
}
Button("Save & Restart") {
controller.hfToken = pendingHFToken
if controller.status == .running || controller.status == .starting {
controller.restart()
}
}
.font(.caption2)
.disabled(pendingHFToken == controller.hfToken)
}
}
Divider()
HStack {
Toggle(
"Enable Image Models (experimental)", isOn: $pendingEnableImageModels
)
.toggleStyle(.switch)
.font(.caption2)
.onAppear {
pendingEnableImageModels = controller.enableImageModels
}
Spacer()
Button("Save & Restart") {
controller.enableImageModels = pendingEnableImageModels
if controller.status == .running || controller.status == .starting {
controller.restart()
}
}
.font(.caption2)
.disabled(pendingEnableImageModels == controller.enableImageModels)
}
HoverButton(title: "Check for Updates", small: true) {
updater.checkForUpdates()
}
debugSection
HoverButton(title: "Uninstall", tint: .red, small: true) {
showUninstallConfirmationAlert()
}
.disabled(uninstallInProgress)
}
.transition(.opacity)
HoverButton(title: "Check for Updates", small: true) {
updater.checkForUpdates()
}
debugSection
HoverButton(title: "Uninstall", tint: .red, small: true) {
showUninstallConfirmationAlert()
}
.disabled(uninstallInProgress)
}
.animation(.easeInOut(duration: 0.25), value: showAdvanced)
}
private func controlButton(title: String, tint: Color = .primary, action: @escaping () -> Void)
@@ -371,25 +299,57 @@ struct ContentView: View {
}
private var dashboardButton: some View {
Button {
guard let url = URL(string: "http://localhost:52415/") else { return }
NSWorkspace.shared.open(url)
} label: {
HStack {
Image(systemName: "arrow.up.right.square")
.imageScale(.small)
Text("Dashboard")
.fontWeight(.medium)
VStack(spacing: 6) {
Button {
guard let url = URL(string: "http://localhost:52415/") else { return }
NSWorkspace.shared.open(url)
} label: {
HStack {
Image(systemName: "globe")
.imageScale(.small)
Text("Web Dashboard")
.fontWeight(.medium)
Spacer()
Image(systemName: "arrow.up.right")
.imageScale(.small)
.foregroundColor(.secondary)
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(red: 1.0, green: 0.87, blue: 0.0).opacity(0.2))
)
}
.buttonStyle(.plain)
// Base URL for API integrations (SillyTavern, etc.)
HStack(spacing: 6) {
Text("Base URL:")
.font(.caption2)
.foregroundColor(.secondary)
Text("localhost:52415/v1")
.font(.system(.caption2, design: .monospaced))
.foregroundColor(.primary)
Spacer()
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString("http://localhost:52415/v1", forType: .string)
baseURLCopied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
baseURLCopied = false
}
} label: {
Image(systemName: baseURLCopied ? "checkmark" : "doc.on.doc")
.imageScale(.small)
.foregroundColor(baseURLCopied ? .green : .secondary)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.plain)
.help("Copy API base URL")
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(red: 1.0, green: 0.87, blue: 0.0).opacity(0.2))
)
}
.buttonStyle(.plain)
.padding(.bottom, 4)
}
+10 -1
View File
@@ -21,7 +21,9 @@ struct EXOApp: App {
@StateObject private var localNetworkChecker: LocalNetworkChecker
@StateObject private var updater: SparkleUpdater
@StateObject private var thunderboltBridgeService: ThunderboltBridgeService
@StateObject private var settingsWindowController: SettingsWindowController
private let terminationObserver: TerminationObserver
private let firstLaunchPopout = FirstLaunchPopout()
private let ciContext = CIContext(options: nil)
init() {
@@ -43,12 +45,13 @@ struct EXOApp: App {
_updater = StateObject(wrappedValue: updater)
let thunderboltBridge = ThunderboltBridgeService(clusterStateService: service)
_thunderboltBridgeService = StateObject(wrappedValue: thunderboltBridge)
_settingsWindowController = StateObject(wrappedValue: SettingsWindowController())
enableLaunchAtLoginIfNeeded()
// Install LaunchDaemon to disable Thunderbolt Bridge on startup (prevents network loops)
NetworkSetupHelper.promptAndInstallIfNeeded()
// Check local network access periodically (warning disappears when user grants permission)
localNetwork.startPeriodicChecking(interval: 10)
controller.scheduleLaunch(after: 15)
controller.scheduleLaunch(after: 5)
service.startPolling()
networkStatus.startPolling()
}
@@ -62,6 +65,12 @@ struct EXOApp: App {
.environmentObject(localNetworkChecker)
.environmentObject(updater)
.environmentObject(thunderboltBridgeService)
.environmentObject(settingsWindowController)
.onReceive(controller.$isFirstLaunchReady) { ready in
if ready {
firstLaunchPopout.show()
}
}
} label: {
menuBarIcon
}
+10
View File
@@ -5,6 +5,7 @@ import Foundation
private let customNamespaceKey = "EXOCustomNamespace"
private let hfTokenKey = "EXOHFToken"
private let enableImageModelsKey = "EXOEnableImageModels"
private let hasLaunchedBeforeKey = "EXOHasLaunchedBefore"
@MainActor
final class ExoProcessController: ObservableObject {
@@ -60,6 +61,9 @@ final class ExoProcessController: ObservableObject {
}
}
/// Fires once when EXO transitions to `.running` for the very first time (fresh install).
@Published private(set) var isFirstLaunchReady = false
private var process: Process?
private var runtimeDirectoryURL: URL?
private var pendingLaunchTask: Task<Void, Never>?
@@ -113,6 +117,12 @@ final class ExoProcessController: ObservableObject {
try child.run()
process = child
status = .running
// Detect first-ever launch to trigger welcome popout
if !UserDefaults.standard.bool(forKey: hasLaunchedBeforeKey) {
UserDefaults.standard.set(true, forKey: hasLaunchedBeforeKey)
isFirstLaunchReady = true
}
} catch {
process = nil
status = .failed(message: "Launch error")
+145
View File
@@ -0,0 +1,145 @@
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.
@MainActor
final class FirstLaunchPopout {
private var panel: NSPanel?
private var countdownTask: Task<Void, Never>?
private static let dashboardURL = "http://localhost:52415/"
func show() {
guard panel == nil else { return }
let hostingView = NSHostingView(
rootView: PopoutContentView(
onDismiss: { [weak self] in
self?.dismiss()
},
onOpen: { [weak self] in
self?.openDashboard()
}))
hostingView.frame = NSRect(x: 0, y: 0, width: 300, height: 120)
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 300, height: 120),
styleMask: [.nonactivatingPanel, .hudWindow, .utilityWindow],
backing: .buffered,
defer: false
)
window.contentView = hostingView
window.isFloatingPanel = true
window.level = .floating
window.hasShadow = true
window.isOpaque = false
window.backgroundColor = .clear
window.isMovableByWindowBackground = false
window.hidesOnDeactivate = false
window.collectionBehavior = [.canJoinAllSpaces, .stationary]
// Position near top-right of screen (near menu bar area)
if let screen = NSScreen.main {
let screenFrame = screen.visibleFrame
let x = screenFrame.maxX - window.frame.width - 16
let y = screenFrame.maxY - 8
window.setFrameOrigin(NSPoint(x: x, y: y))
}
window.orderFrontRegardless()
panel = window
// Start countdown: auto-open dashboard after 5 seconds, 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)
if !Task.isCancelled {
dismiss()
}
}
}
}
func dismiss() {
countdownTask?.cancel()
countdownTask = nil
panel?.close()
panel = nil
}
private func openDashboard() {
guard let url = URL(string: Self.dashboardURL) else { return }
NSWorkspace.shared.open(url)
}
}
/// SwiftUI content for the first-launch popout
private struct PopoutContentView: View {
let onDismiss: () -> Void
let onOpen: () -> Void
@State private var countdown = 5
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))
.fontWeight(.semibold)
Spacer()
Button {
onDismiss()
} label: {
Image(systemName: "xmark")
.imageScale(.small)
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
Text("http://localhost:52415")
.font(.system(.caption, design: .monospaced))
.foregroundColor(.secondary)
HStack {
Button {
onOpen()
onDismiss()
} label: {
Text("Open Dashboard")
.font(.caption)
.fontWeight(.medium)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
Spacer()
Text("Opening in \(countdown)s")
.font(.caption2)
.foregroundColor(.secondary)
}
}
.padding(12)
.onAppear {
startCountdown()
}
}
private func startCountdown() {
Task {
while countdown > 0 {
try? await Task.sleep(nanoseconds: 1_000_000_000)
if !Task.isCancelled {
countdown -= 1
}
}
}
}
}
+163
View File
@@ -0,0 +1,163 @@
import SwiftUI
/// Native macOS Settings window following Apple HIG.
/// Organized into General, Model, and Advanced sections.
struct SettingsView: View {
@EnvironmentObject private var controller: ExoProcessController
@EnvironmentObject private var updater: SparkleUpdater
@State private var pendingNamespace: String = ""
@State private var pendingHFToken: String = ""
@State private var pendingEnableImageModels = false
@State private var needsRestart = false
var body: some View {
TabView {
generalTab
.tabItem {
Label("General", systemImage: "gear")
}
modelTab
.tabItem {
Label("Model", systemImage: "cube")
}
aboutTab
.tabItem {
Label("About", systemImage: "info.circle")
}
}
.frame(width: 450, height: 320)
.onAppear {
pendingNamespace = controller.customNamespace
pendingHFToken = controller.hfToken
pendingEnableImageModels = controller.enableImageModels
needsRestart = false
}
}
// MARK: - General Tab
private var generalTab: some View {
Form {
Section {
LabeledContent("Cluster Namespace") {
TextField("default", text: $pendingNamespace)
.textFieldStyle(.roundedBorder)
.frame(width: 200)
}
Text("Nodes with the same namespace form a cluster. Leave empty for default.")
.font(.caption)
.foregroundColor(.secondary)
}
Section {
LabeledContent("HuggingFace Token") {
SecureField("optional", text: $pendingHFToken)
.textFieldStyle(.roundedBorder)
.frame(width: 200)
}
Text("Required for gated models. Get yours at huggingface.co/settings/tokens")
.font(.caption)
.foregroundColor(.secondary)
}
Section {
HStack {
Spacer()
Button("Save & Restart") {
applyGeneralSettings()
}
.disabled(!hasGeneralChanges)
}
}
}
.formStyle(.grouped)
.padding()
}
// MARK: - Model Tab
private var modelTab: some View {
Form {
Section {
Toggle("Enable Image Models (experimental)", isOn: $pendingEnableImageModels)
Text("Allow text-to-image and image-to-image models in the model picker.")
.font(.caption)
.foregroundColor(.secondary)
}
Section {
HStack {
Spacer()
Button("Save & Restart") {
applyModelSettings()
}
.disabled(!hasModelChanges)
}
}
}
.formStyle(.grouped)
.padding()
}
// MARK: - About Tab
private var aboutTab: some View {
Form {
Section {
LabeledContent("Version") {
Text(buildTag)
.textSelection(.enabled)
}
LabeledContent("Commit") {
Text(buildCommit)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
}
Section {
Button("Check for Updates") {
updater.checkForUpdates()
}
}
}
.formStyle(.grouped)
.padding()
}
// MARK: - Helpers
private var hasGeneralChanges: Bool {
pendingNamespace != controller.customNamespace || pendingHFToken != controller.hfToken
}
private var hasModelChanges: Bool {
pendingEnableImageModels != controller.enableImageModels
}
private func applyGeneralSettings() {
controller.customNamespace = pendingNamespace
controller.hfToken = pendingHFToken
restartIfRunning()
}
private func applyModelSettings() {
controller.enableImageModels = pendingEnableImageModels
restartIfRunning()
}
private func restartIfRunning() {
if controller.status == .running || controller.status == .starting {
controller.restart()
}
}
private var buildTag: String {
Bundle.main.infoDictionary?["EXOBuildTag"] as? String ?? "unknown"
}
private var buildCommit: String {
Bundle.main.infoDictionary?["EXOBuildCommit"] as? String ?? "unknown"
}
}
@@ -0,0 +1,38 @@
import AppKit
import SwiftUI
/// Manages a standalone native macOS Settings window.
/// Ensures only one instance exists and brings it to front on repeated opens.
@MainActor
final class SettingsWindowController: ObservableObject {
private var window: NSWindow?
func open(controller: ExoProcessController, updater: SparkleUpdater) {
if let existing = window, existing.isVisible {
existing.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let settingsView = SettingsView()
.environmentObject(controller)
.environmentObject(updater)
let hostingView = NSHostingView(rootView: settingsView)
let newWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 450, height: 320),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
newWindow.title = "EXO Settings"
newWindow.contentView = hostingView
newWindow.center()
newWindow.isReleasedWhenClosed = false
newWindow.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
window = newWindow
}
}
+2 -2
View File
@@ -1805,7 +1805,7 @@ class AppStore {
assistantMessage.id,
(msg) => {
msg.content =
"Error: No model available. Please launch an instance first.";
"No model is loaded yet. Select a model from the sidebar to get started — it will download and load automatically.";
},
);
this.syncActiveMessagesIfNeeded(targetConversationId);
@@ -2243,7 +2243,7 @@ class AppStore {
const modelToUse = this.getModelForRequest();
if (!modelToUse) {
throw new Error(
"No model selected and no running instances available. Please launch an instance first.",
"No model is loaded yet. Select a model from the sidebar to get started — it will download and load automatically.",
);
}
File diff suppressed because it is too large Load Diff
+87 -46
View File
@@ -2,9 +2,10 @@
"""Generate a DMG background image for the EXO installer.
Creates a 660x400 PNG with:
- Dark gradient background matching the EXO brand
- Right-pointing arrow between app and Applications
- "Drag to install" instruction text
- Clean dark gradient background (no grid)
- Minimal right-pointing arrow between app and Applications
- White "Drag to install" instruction text
- Premium style inspired by Slack/Discord/VSCode DMGs
Usage:
python3 generate-background.py output.png
@@ -12,6 +13,7 @@ Usage:
from __future__ import annotations
import math
import struct
import sys
import zlib
@@ -56,32 +58,71 @@ def _lerp(a: int, b: int, t: float) -> int:
return max(0, min(255, int(a + (b - a) * t)))
def _draw_arrow(
def _blend(
bg: tuple[int, int, int, int], fg: tuple[int, int, int, int]
) -> tuple[int, int, int, int]:
"""Alpha-blend fg over bg."""
fa = fg[3] / 255.0
ba = bg[3] / 255.0
oa = fa + ba * (1 - fa)
if oa == 0:
return (0, 0, 0, 0)
r = int((fg[0] * fa + bg[0] * ba * (1 - fa)) / oa)
g = int((fg[1] * fa + bg[1] * ba * (1 - fa)) / oa)
b = int((fg[2] * fa + bg[2] * ba * (1 - fa)) / oa)
return (r, g, b, int(oa * 255))
def _draw_smooth_arrow(
pixels: list[list[tuple[int, int, int, int]]],
cx: int,
cy: int,
color: tuple[int, int, int, int],
color: tuple[int, int, int],
) -> None:
"""Draw a simple right-pointing arrow at (cx, cy)."""
# Shaft: horizontal line
shaft_len = 60
shaft_thickness = 3
for dx in range(-shaft_len, shaft_len + 1):
for dy in range(-shaft_thickness, shaft_thickness + 1):
y = cy + dy
x = cx + dx
if 0 <= y < len(pixels) and 0 <= x < len(pixels[0]):
pixels[y][x] = color
"""Draw a clean, minimal right-pointing arrow with anti-aliased edges."""
height = len(pixels)
width = len(pixels[0]) if height > 0 else 0
# Arrowhead: triangle pointing right
head_size = 20
for i in range(head_size):
spread = int(i * 1.2)
x = cx + shaft_len + i
for dy in range(-spread, spread + 1):
y = cy + dy
if 0 <= y < len(pixels) and 0 <= x < len(pixels[0]):
pixels[y][x] = color
# Slim shaft
shaft_half_len = 32
shaft_half_thickness = 1.5
for x in range(cx - shaft_half_len, cx + shaft_half_len + 1):
for y_offset_10 in range(-30, 31): # sub-pixel sampling
y_f = cy + y_offset_10 / 10.0
yi = int(y_f)
dist = abs(y_f - cy)
if dist <= shaft_half_thickness and 0 <= yi < height and 0 <= x < width:
# Smooth edge falloff
edge_dist = shaft_half_thickness - dist
alpha = min(1.0, edge_dist * 2.0)
a = int(alpha * 200)
fg = (color[0], color[1], color[2], a)
pixels[yi][x] = _blend(pixels[yi][x], fg)
# Chevron arrowhead (> shape) — clean and modern
head_x = cx + shaft_half_len - 2
head_size = 14
stroke_width = 2.0
for i_10 in range(head_size * 10):
t = i_10 / 10.0
# Top arm of chevron
px_f = head_x + t
py_top_f = cy - t
# Bottom arm of chevron
py_bot_f = cy + t
for dy_10 in range(int(-stroke_width * 10), int(stroke_width * 10) + 1):
for arm_py in [py_top_f, py_bot_f]:
py = int(arm_py + dy_10 / 10.0)
px = int(px_f)
if 0 <= py < height and 0 <= px < width:
dist = abs(dy_10 / 10.0)
alpha = max(0.0, min(1.0, (stroke_width - dist) * 1.5))
a = int(alpha * 200)
fg = (color[0], color[1], color[2], a)
pixels[py][px] = _blend(pixels[py][px], fg)
def _draw_text_pixel(
@@ -93,7 +134,7 @@ def _draw_text_pixel(
scale: int = 1,
) -> None:
"""Draw simple pixel text. Limited to the phrase 'Drag to install'."""
# 5x7 pixel font for uppercase + lowercase letters we need
# 5x7 pixel font for the letters we need
glyphs: dict[str, list[str]] = {
"D": ["1110 ", "1 01", "1 01", "1 01", "1 01", "1 01", "1110 "],
"r": [" ", " ", " 110 ", "1 ", "1 ", "1 ", "1 "],
@@ -122,7 +163,7 @@ def _draw_text_pixel(
py = y + row_idx * scale + sy
px = cursor_x + col_idx * scale + sx
if 0 <= py < len(pixels) and 0 <= px < len(pixels[0]):
pixels[py][px] = color
pixels[py][px] = _blend(pixels[py][px], color)
cursor_x += (len(glyph[0]) + 1) * scale
@@ -130,33 +171,33 @@ def generate_background(output_path: str) -> None:
"""Generate the DMG background image."""
width, height = 660, 400
# Build gradient background: dark gray to slightly darker
top_color = (30, 30, 30) # #1e1e1e — matches exo-dark-gray
bottom_color = (18, 18, 18) # #121212 — matches exo-black
# Clean dark gradient — no grid, no noise
top_color = (28, 28, 30) # macOS dark mode surface
bottom_color = (16, 16, 18) # slightly darker at bottom
pixels: list[list[tuple[int, int, int, int]]] = []
for y in range(height):
t = y / (height - 1)
r = _lerp(top_color[0], bottom_color[0], t)
g = _lerp(top_color[1], bottom_color[1], t)
b = _lerp(top_color[2], bottom_color[2], t)
pixels.append([(r, g, b, 255)] * width)
# Draw subtle grid lines (matches the exo dashboard grid)
grid_color = (40, 40, 40, 255)
for y in range(0, height, 40):
row: list[tuple[int, int, int, int]] = []
for x in range(width):
pixels[y][x] = grid_color
for x in range(0, width, 40):
for y in range(height):
pixels[y][x] = grid_color
# Radial vignette: slightly brighter in center for depth
dx = (x - width / 2) / (width / 2)
dy = (y - height * 0.45) / (height / 2)
dist = math.sqrt(dx * dx + dy * dy)
vignette = max(0.0, 1.0 - dist * 0.3)
# Draw the arrow in the center (between app icon at x=155 and Applications at x=505)
arrow_color = (200, 180, 50, 255) # EXO yellow
_draw_arrow(pixels, width // 2, 200, arrow_color)
r = _lerp(top_color[0], bottom_color[0], t) + int(vignette * 6)
g = _lerp(top_color[1], bottom_color[1], t) + int(vignette * 6)
b = _lerp(top_color[2], bottom_color[2], t) + int(vignette * 6)
row.append((min(255, r), min(255, g), min(255, b), 255))
pixels.append(row)
# Draw instruction text below the arrow
text_color = (150, 150, 150, 200)
# Draw a clean, minimal arrow between app (x=155) and Applications (x=505)
arrow_color = (255, 255, 255) # white — clean and visible
_draw_smooth_arrow(pixels, width // 2, 200, arrow_color)
# Draw instruction text below the arrow — white for readability
text_color = (255, 255, 255, 140) # white, semi-transparent
_draw_text_pixel(pixels, 268, 310, "Drag to install", text_color, scale=2)
# Write PNG