Skip to content

Commit 66fe453

Browse files
committed
feat(chat): unified iOS + macOS premium UI overhaul
## SwiftLM Chat — Unified Experience (iPhone 13 Pro 6GB + macOS) ### Design System - Add Theme.swift: indigo/violet dark palette, glassmorphic surface tokens, gradient definitions, corner radii, animation constants, AvatarView, GeneratingDots, glassCard modifier ### App Entry Point - AppearanceStore: persists Dark/Light/System preference to UserDefaults - Default dark mode (.preferredColorScheme), indigo accent + tint ### Chat Interface (ChatView.swift) - Deep canvas background (SwiftLMTheme.background) - Animated empty state: bolt brand mark + 'Run any model. Locally. Instantly.' - Frosted glass input pill with indigo glow ring on focus - Circular send/stop button with gradient fill (animates between states) - iOS toolbar: animated GeneratingDots in status capsule during generation ### Message Bubbles (MessageBubble.swift) - User: indigo→violet gradient bubble with drop shadow - Assistant: glassmorphic ultraThinMaterial bubble with stroke border - Avatar: indigo→cyan gradient ring with pulse animation during generation - Inline blinking cursor (replaces floating overlay) - Thinking panel: purple-tinted collapsible with animated chevron - Timestamps hidden by default, revealed on tap ### Models Tab (ModelsView.swift) - Dark canvas with card-wrapped sections and glass surface - Hero card: gradient glow orb, chiplet metadata, live state badge - Catalog cards: spring scale tap animation, color-coded fit borders - Section headers with horizontal divider rules ### macOS Sidebar (RootView.swift) - Branded header: bolt icon + SwiftLM wordmark + v1.0 version chip - GeneratingDots animation in engine status section - Full dark background throughout split view ### Settings (SettingsView.swift) - Dark form background, themed sliders and toggles - Appearance section: Dark/Light/System picker (persisted) - iOS Performance Mode toggle: loosens RAM budget 40% → 55% - Auto-Unload in Background toggle - Live RAM display in About section ### iPhone 13 Pro 6GB Tuning - ModelDownloadManager: Performance Mode reads swiftlm.performanceMode from UserDefaults → 55% vs 40% RAM budget - Conservative (40%): 2.4 GB usable → Qwen3 0.6B/1.7B/4B, Llama 3.2 3B - Performance (55%): 3.3 GB usable → unlocks Qwen3 8B, Qwen 2.5 7B - ModelCatalog: FitStatus thresholds tightened for 6GB (75%/90%), MoE streaming cap lowered from 4x to 3x RAM - generate_xcodeproj.py: Theme.swift added to build sources Builds: iOS Simulator ✅ macOS native ✅
1 parent afb677c commit 66fe453

10 files changed

Lines changed: 1520 additions & 715 deletions

File tree

Sources/MLXInferenceCore/ModelCatalog.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,9 +227,12 @@ public enum ModelCatalog {
227227
on device: DeviceProfile = .current
228228
) -> FitStatus {
229229
let ram = device.physicalRAMGB
230+
// On 6 GB devices: 75% = 4.5 GB comfortable, 90% = 5.4 GB tight.
231+
// MoE models with expert streaming can run at up to 3x RAM via NVMe paging.
230232
if model.ramRequiredGB <= ram * 0.75 { return .fits }
231233
if model.ramRequiredGB <= ram * 0.90 { return .tight }
232-
if model.isMoE && model.ramRequiredGB <= ram * 4.0 { return .requiresFlash }
234+
if model.isMoE && model.ramRequiredGB <= ram * 3.0 { return .requiresFlash }
233235
return .tooLarge
234236
}
235237
}
238+

Sources/MLXInferenceCore/ModelDownloadManager.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,12 @@ public final class ModelDownloadManager: ObservableObject {
6868
private var downloadTasks: [String: Task<Void, Error>] = [:]
6969

7070
// MARK: iOS RAM budget
71-
/// On iOS, use a tighter RAM budget to avoid jetsam (40% of physical RAM).
71+
/// On iOS, use 40% (conservative, avoids jetsam) or 55% in Performance Mode.
7272
/// On macOS, use 75% (generous, no jetsam).
7373
public static var ramBudgetFraction: Double {
7474
#if os(iOS)
75-
return 0.40
75+
let performanceMode = UserDefaults.standard.bool(forKey: "swiftlm.performanceMode")
76+
return performanceMode ? 0.55 : 0.40
7677
#else
7778
return 0.75
7879
#endif

SwiftLMChat/SwiftLMChat/SwiftLMChatApp.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,43 @@
11
// SwiftLMChatApp.swift — App entry point (iOS + macOS)
22
import SwiftUI
33

4+
// MARK: — Appearance Store (persists dark/light/system preference)
5+
6+
final class AppearanceStore: ObservableObject {
7+
private static let key = "swiftlm.colorScheme" // "dark" | "light" | "system"
8+
9+
@Published var preference: String {
10+
didSet { UserDefaults.standard.set(preference, forKey: Self.key) }
11+
}
12+
13+
init() {
14+
preference = UserDefaults.standard.string(forKey: Self.key) ?? "dark"
15+
}
16+
17+
var colorScheme: ColorScheme? {
18+
switch preference {
19+
case "dark": return .dark
20+
case "light": return .light
21+
default: return nil
22+
}
23+
}
24+
}
25+
26+
// MARK: — App
27+
428
@main
529
struct SwiftLMChatApp: App {
630
@StateObject private var engine = InferenceEngine()
31+
@StateObject private var appearance = AppearanceStore()
732

833
var body: some Scene {
934
WindowGroup {
1035
RootView()
1136
.environmentObject(engine)
37+
.environmentObject(appearance)
38+
.preferredColorScheme(appearance.colorScheme)
39+
.accentColor(SwiftLMTheme.accent)
40+
.tint(SwiftLMTheme.accent)
1241
}
1342
#if os(macOS)
1443
.commands {
@@ -29,3 +58,4 @@ struct SwiftLMChatApp: App {
2958
extension Notification.Name {
3059
static let showModelPicker = Notification.Name("showModelPicker")
3160
}
61+
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Theme.swift — SwiftLM Chat design system
2+
// Single source of truth for colors, gradients, radii, and animations.
3+
import SwiftUI
4+
5+
// ─────────────────────────────────────────────────────────────────────────────
6+
// MARK: — Color Tokens
7+
// ─────────────────────────────────────────────────────────────────────────────
8+
9+
public enum SwiftLMTheme {
10+
11+
// ── Background layers ─────────────────────────────────────────────────────
12+
/// Deep navy-black canvas — the app's primary background.
13+
public static let background = Color(hue: 0.67, saturation: 0.20, brightness: 0.07)
14+
/// Slightly elevated surface for cards and panels.
15+
public static let surface = Color(hue: 0.67, saturation: 0.18, brightness: 0.12)
16+
/// Second elevation — dialogs, popovers.
17+
public static let surfaceElevated = Color(hue: 0.67, saturation: 0.15, brightness: 0.17)
18+
/// Subtle divider / separator.
19+
public static let divider = Color.white.opacity(0.08)
20+
21+
// ── Brand accents ─────────────────────────────────────────────────────────
22+
/// Primary accent — vivid indigo.
23+
public static let accent = Color(hue: 0.70, saturation: 0.90, brightness: 0.95)
24+
/// Secondary accent — electric violet.
25+
public static let accentSecondary = Color(hue: 0.76, saturation: 0.85, brightness: 0.95)
26+
/// Cyan highlight — used in avatars and MoE badges.
27+
public static let cyan = Color(hue: 0.54, saturation: 0.80, brightness: 0.95)
28+
29+
// ── Semantic ──────────────────────────────────────────────────────────────
30+
public static let success = Color(hue: 0.40, saturation: 0.70, brightness: 0.80)
31+
public static let warning = Color(hue: 0.10, saturation: 0.85, brightness: 0.95)
32+
public static let error = Color(hue: 0.02, saturation: 0.80, brightness: 0.90)
33+
34+
// ── Text ──────────────────────────────────────────────────────────────────
35+
public static let textPrimary = Color.white
36+
public static let textSecondary = Color.white.opacity(0.60)
37+
public static let textTertiary = Color.white.opacity(0.35)
38+
39+
// ─────────────────────────────────────────────────────────────────────────
40+
// MARK: — Gradients
41+
// ─────────────────────────────────────────────────────────────────────────
42+
43+
/// User message bubble fill.
44+
public static let userBubbleGradient = LinearGradient(
45+
colors: [
46+
Color(hue: 0.70, saturation: 0.80, brightness: 0.90),
47+
Color(hue: 0.76, saturation: 0.82, brightness: 0.88)
48+
],
49+
startPoint: .topLeading, endPoint: .bottomTrailing
50+
)
51+
52+
/// AI avatar ring gradient.
53+
public static let avatarGradient = LinearGradient(
54+
colors: [
55+
Color(hue: 0.70, saturation: 0.85, brightness: 0.95),
56+
Color(hue: 0.54, saturation: 0.80, brightness: 0.95)
57+
],
58+
startPoint: .topLeading, endPoint: .bottomTrailing
59+
)
60+
61+
/// Hero card gradient (active model card).
62+
public static let heroGradient = LinearGradient(
63+
colors: [
64+
Color(hue: 0.70, saturation: 0.75, brightness: 0.30),
65+
Color(hue: 0.76, saturation: 0.80, brightness: 0.22)
66+
],
67+
startPoint: .topLeading, endPoint: .bottomTrailing
68+
)
69+
70+
/// Thinking panel tint.
71+
public static let thinkingGradient = LinearGradient(
72+
colors: [
73+
Color(hue: 0.76, saturation: 0.40, brightness: 0.18),
74+
Color(hue: 0.72, saturation: 0.35, brightness: 0.16)
75+
],
76+
startPoint: .topLeading, endPoint: .bottomTrailing
77+
)
78+
79+
// ─────────────────────────────────────────────────────────────────────────
80+
// MARK: — Corner Radii
81+
// ─────────────────────────────────────────────────────────────────────────
82+
83+
public static let radiusSmall: CGFloat = 8
84+
public static let radiusMedium: CGFloat = 14
85+
public static let radiusLarge: CGFloat = 20
86+
public static let radiusXL: CGFloat = 28
87+
88+
// ─────────────────────────────────────────────────────────────────────────
89+
// MARK: — Animation
90+
// ─────────────────────────────────────────────────────────────────────────
91+
92+
public static let spring = Animation.spring(response: 0.4, dampingFraction: 0.75)
93+
public static let quickSpring = Animation.spring(response: 0.25, dampingFraction: 0.80)
94+
95+
// ─────────────────────────────────────────────────────────────────────────
96+
// MARK: — Shadows
97+
// ─────────────────────────────────────────────────────────────────────────
98+
99+
public struct ShadowStyle {
100+
let color: Color
101+
let radius: CGFloat
102+
let x: CGFloat
103+
let y: CGFloat
104+
}
105+
106+
public static let shadowCard = ShadowStyle(
107+
color: Color.black.opacity(0.35), radius: 12, x: 0, y: 6
108+
)
109+
public static let shadowBubble = ShadowStyle(
110+
color: Color.black.opacity(0.20), radius: 4, x: 0, y: 2
111+
)
112+
public static let shadowGlow = ShadowStyle(
113+
color: Color(hue: 0.70, saturation: 0.80, brightness: 0.90).opacity(0.45),
114+
radius: 16, x: 0, y: 0
115+
)
116+
}
117+
118+
// ─────────────────────────────────────────────────────────────────────────────
119+
// MARK: — View Modifiers
120+
// ─────────────────────────────────────────────────────────────────────────────
121+
122+
extension View {
123+
/// Apply a glowing indigo ring — used on focused input and hero cards.
124+
func glowRing(color: Color = SwiftLMTheme.accent, radius: CGFloat = 8, active: Bool = true) -> some View {
125+
self.shadow(color: active ? color.opacity(0.55) : .clear, radius: radius)
126+
}
127+
128+
/// Glassmorphic card surface.
129+
func glassCard(cornerRadius: CGFloat = SwiftLMTheme.radiusMedium) -> some View {
130+
self
131+
.background(.ultraThinMaterial)
132+
.background(SwiftLMTheme.surface.opacity(0.65))
133+
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
134+
.overlay(
135+
RoundedRectangle(cornerRadius: cornerRadius)
136+
.strokeBorder(Color.white.opacity(0.09), lineWidth: 1)
137+
)
138+
}
139+
}
140+
141+
// ─────────────────────────────────────────────────────────────────────────────
142+
// MARK: — Reusable badge helpers
143+
// ─────────────────────────────────────────────────────────────────────────────
144+
145+
struct ThemedBadge: View {
146+
let text: String
147+
let color: Color
148+
149+
var body: some View {
150+
Text(text)
151+
.font(.system(size: 9, weight: .bold))
152+
.padding(.horizontal, 6)
153+
.padding(.vertical, 2)
154+
.background(color.opacity(0.18), in: Capsule())
155+
.foregroundStyle(color)
156+
}
157+
}
158+
159+
// ─────────────────────────────────────────────────────────────────────────────
160+
// MARK: — Animated generating indicator (three dots)
161+
// ─────────────────────────────────────────────────────────────────────────────
162+
163+
struct GeneratingDots: View {
164+
@State private var phase = 0
165+
166+
var body: some View {
167+
HStack(spacing: 4) {
168+
ForEach(0..<3) { i in
169+
Circle()
170+
.frame(width: 5, height: 5)
171+
.foregroundStyle(SwiftLMTheme.accent)
172+
.scaleEffect(phase == i ? 1.5 : 0.8)
173+
.opacity(phase == i ? 1.0 : 0.45)
174+
.animation(
175+
.easeInOut(duration: 0.45).repeatForever().delay(Double(i) * 0.18),
176+
value: phase
177+
)
178+
}
179+
}
180+
.onAppear { withAnimation { phase = 1 } }
181+
}
182+
}
183+
184+
// ─────────────────────────────────────────────────────────────────────────────
185+
// MARK: — Pulsing avatar ring
186+
// ─────────────────────────────────────────────────────────────────────────────
187+
188+
struct AvatarView: View {
189+
var isGenerating: Bool = false
190+
var size: CGFloat = 30
191+
192+
@State private var pulse: Bool = false
193+
194+
var body: some View {
195+
ZStack {
196+
// Outer glow ring when generating
197+
if isGenerating {
198+
Circle()
199+
.stroke(SwiftLMTheme.accent.opacity(pulse ? 0.55 : 0.15), lineWidth: 2)
200+
.frame(width: size + 8, height: size + 8)
201+
.scaleEffect(pulse ? 1.12 : 1.0)
202+
.animation(
203+
.easeInOut(duration: 1.0).repeatForever(autoreverses: true),
204+
value: pulse
205+
)
206+
}
207+
208+
// Avatar circle
209+
Circle()
210+
.fill(SwiftLMTheme.avatarGradient)
211+
.frame(width: size, height: size)
212+
.overlay(
213+
Image(systemName: "bolt.fill")
214+
.font(.system(size: size * 0.40, weight: .semibold))
215+
.foregroundStyle(.white)
216+
)
217+
}
218+
.onAppear { if isGenerating { pulse = true } }
219+
.onChange(of: isGenerating) { _, gen in
220+
pulse = gen
221+
}
222+
}
223+
}

0 commit comments

Comments
 (0)