SPB Git

spb/zyquo-atlas

Public License

The AI-native macOS web browser — every surface, intelligent.

phase4: theming engine + UI — AtlasTheme + 10 built-in themes, ThemeEngine (live apply, per-profile persistence, import/export, custom editor), top/left tab layouts + density, customizable start page, customization panel; light+dark verified; Phase 4 gate PASSED

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed (Jul 30, 2026) parent 2bc98e6 Browse files

14 changed files +1,051 −91

modified Sources/ZyquoAtlas/App/ZyquoAtlasApp.swift +3 −0
@@ -16,11 +16,14 @@ import AppKit
16 struct ZyquoAtlasApp: App { 16 struct ZyquoAtlasApp: App {
17 @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate 17 @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
18 @StateObject private var appEnvironment = AppEnvironment() 18 @StateObject private var appEnvironment = AppEnvironment()
19+ @StateObject private var themeEngine = ThemeEngine()
19 20
20 var body: some Scene { 21 var body: some Scene {
21 WindowGroup("Zyquo Atlas") { 22 WindowGroup("Zyquo Atlas") {
22 BrowserWindowView() 23 BrowserWindowView()
23 .environmentObject(appEnvironment) 24 .environmentObject(appEnvironment)
25+ .environmentObject(themeEngine)
26+ .preferredColorScheme(themeEngine.theme.colorScheme)
24 } 27 }
25 .windowStyle(.hiddenTitleBar) 28 .windowStyle(.hiddenTitleBar)
26 .windowToolbarStyle(.unified) 29 .windowToolbarStyle(.unified)
added Sources/ZyquoAtlas/DesignSystem/AtlasTheme.swift +134 −0
@@ -0,0 +1,134 @@
1+//
2+// AtlasTheme.swift
3+// Zyquo Atlas
4+//
5+// Author: Simon-Pierre Boucher
6+// Mail: contact@spboucher.ai
7+//
8+// The user-overridable theme value type (Phase 4.2). A theme is a named color
9+// palette + background + appearance, Codable so it imports/exports as a small
10+// JSON file. The base tokens in ZyquoTheme seed the flagship "Atlas Light"
11+// theme (Phase 4.1); the ThemeEngine swaps the active AtlasTheme live. Layout
12+// and typography preferences live in LayoutSettings (separate from color
13+// themes) so changing colors never disturbs the user's layout.
14+//
15+
16+import SwiftUI
17+
18+extension Color {
19+ /// 0xRRGGBB → Color (sRGB).
20+ init(hex: UInt32) {
21+ self.init(.sRGB,
22+ red: Double((hex >> 16) & 0xFF) / 255,
23+ green: Double((hex >> 8) & 0xFF) / 255,
24+ blue: Double(hex & 0xFF) / 255,
25+ opacity: 1)
26+ }
27+}
28+
29+enum ThemeAppearance: String, Codable, Hashable {
30+ case light, dark
31+}
32+
33+/// The 13 semantic color slots (the flagship "Atlas Light" values are the
34+/// Phase 4.1 base spec).
35+struct AtlasPalette: Codable, Hashable {
36+ var background: UInt32
37+ var surface: UInt32
38+ var surfaceSecondary: UInt32
39+ var accent: UInt32
40+ var accentIndigo: UInt32
41+ var accentSubtle: UInt32
42+ var textPrimary: UInt32
43+ var textSecondary: UInt32
44+ var textTertiary: UInt32
45+ var border: UInt32
46+ var success: UInt32
47+ var warning: UInt32
48+ var danger: UInt32
49+}
50+
51+/// Background behind the chrome / new-tab page.
52+struct BackgroundStyle: Codable, Hashable {
53+ enum Kind: String, Codable { case solid, gradient, image }
54+ var kind: Kind = .solid
55+ var colorA: UInt32 = 0xFAFBFC
56+ var colorB: UInt32 = 0xE6F5F3
57+ /// Absolute path to a wallpaper image (image kind only).
58+ var imagePath: String?
59+}
60+
61+struct AtlasTheme: Codable, Hashable, Identifiable {
62+ var id: String
63+ var name: String
64+ var appearance: ThemeAppearance
65+ var palette: AtlasPalette
66+ var background: BackgroundStyle
67+
68+ // MARK: - Resolved SwiftUI colors (views read these)
69+
70+ var backgroundColor: Color { Color(hex: palette.background) }
71+ var surface: Color { Color(hex: palette.surface) }
72+ var surfaceSecondary: Color { Color(hex: palette.surfaceSecondary) }
73+ var accent: Color { Color(hex: palette.accent) }
74+ var accentIndigo: Color { Color(hex: palette.accentIndigo) }
75+ var accentSubtle: Color { Color(hex: palette.accentSubtle) }
76+ var textPrimary: Color { Color(hex: palette.textPrimary) }
77+ var textSecondary: Color { Color(hex: palette.textSecondary) }
78+ var textTertiary: Color { Color(hex: palette.textTertiary) }
79+ var border: Color { Color(hex: palette.border) }
80+ var success: Color { Color(hex: palette.success) }
81+ var warning: Color { Color(hex: palette.warning) }
82+ var danger: Color { Color(hex: palette.danger) }
83+
84+ var colorScheme: ColorScheme { appearance == .dark ? .dark : .light }
85+
86+ /// The chrome/new-tab background as a SwiftUI view.
87+ @ViewBuilder
88+ var backgroundView: some View {
89+ switch background.kind {
90+ case .solid:
91+ backgroundColor
92+ case .gradient:
93+ LinearGradient(colors: [Color(hex: background.colorA), Color(hex: background.colorB)],
94+ startPoint: .top, endPoint: .bottom)
95+ case .image:
96+ if let path = background.imagePath, let img = NSImage(contentsOfFile: path) {
97+ Image(nsImage: img).resizable().aspectRatio(contentMode: .fill)
98+ } else {
99+ backgroundColor
100+ }
101+ }
102+ }
103+}
104+
105+// MARK: - Layout & typography (separate from color themes)
106+
107+enum TabPosition: String, Codable, Hashable, CaseIterable {
108+ case top, left
109+}
110+
111+enum Density: String, Codable, Hashable, CaseIterable {
112+ case compact, comfortable
113+}
114+
115+/// User layout/typography preferences, persisted per profile and applied live.
116+struct LayoutSettings: Codable, Hashable {
117+ var tabPosition: TabPosition = .top
118+ var density: Density = .comfortable
119+ var uiScale: Double = 1.0 // 0.85 … 1.30
120+ var defaultPageZoom: Double = 1.0
121+ var showBookmarksBar: Bool = false
122+ var translucency: Bool = false
123+
124+ // Density-derived metrics.
125+ var toolbarHeight: CGFloat { density == .compact ? 38 : 44 }
126+ var tabBarHeight: CGFloat { density == .compact ? 30 : 36 }
127+ var verticalTabWidth: CGFloat { 220 }
128+ var rowSpacing: CGFloat { density == .compact ? ZyquoSpacing.xxs : ZyquoSpacing.xs }
129+
130+ /// UI font at a base size, scaled by the user's uiScale.
131+ func font(_ base: Double, weight: Font.Weight = .regular) -> Font {
132+ .system(size: base * uiScale, weight: weight)
133+ }
134+}
added Sources/ZyquoAtlas/DesignSystem/BuiltInThemes.swift +122 −0
@@ -0,0 +1,122 @@
1+//
2+// BuiltInThemes.swift
3+// Zyquo Atlas
4+//
5+// Author: Simon-Pierre Boucher
6+// Mail: contact@spboucher.ai
7+//
8+// The shipped theme gallery (Phase 4.2): the flagship teal-indigo "Atlas
9+// Light" (the Phase 4.1 base spec) and its derived dark, plus eight more
10+// light/dark themes. The custom-theme editor starts from a copy of any of
11+// these. Status colors stay legible on every background.
12+//
13+
14+import Foundation
15+
16+enum BuiltInThemes {
17+ static let all: [AtlasTheme] = [
18+ atlasLight, atlasDark, cartographer, midnight, meridian,
19+ terra, slate, nocturne, roseQuartz, aurora,
20+ ]
21+
22+ static let defaultLightID = "atlas-light"
23+ static let defaultDarkID = "atlas-dark"
24+
25+ static func theme(id: String) -> AtlasTheme? { all.first { $0.id == id } }
26+
27+ // MARK: - Flagship (Phase 4.1 base spec)
28+
29+ static let atlasLight = AtlasTheme(
30+ id: "atlas-light", name: "Atlas Light", appearance: .light,
31+ palette: AtlasPalette(
32+ background: 0xFAFBFC, surface: 0xFFFFFF, surfaceSecondary: 0xF1F4F6,
33+ accent: 0x1FA9A0, accentIndigo: 0x4E63E0, accentSubtle: 0xE6F5F3,
34+ textPrimary: 0x1A1D22, textSecondary: 0x6B7280, textTertiary: 0x9CA3AF,
35+ border: 0xE5E9EC, success: 0x2FA36B, warning: 0xD9822B, danger: 0xD64545),
36+ background: BackgroundStyle(kind: .solid, colorA: 0xFAFBFC))
37+
38+ static let atlasDark = AtlasTheme(
39+ id: "atlas-dark", name: "Atlas Dark", appearance: .dark,
40+ palette: AtlasPalette(
41+ background: 0x14171C, surface: 0x1C2029, surfaceSecondary: 0x252A34,
42+ accent: 0x35C3B8, accentIndigo: 0x6E80EE, accentSubtle: 0x1B3330,
43+ textPrimary: 0xE8EBF0, textSecondary: 0x9AA1AE, textTertiary: 0x69707E,
44+ border: 0x2B303A, success: 0x43BD83, warning: 0xE59A4D, danger: 0xE36363),
45+ background: BackgroundStyle(kind: .solid, colorA: 0x14171C))
46+
47+ // MARK: - Light themes
48+
49+ static let cartographer = AtlasTheme(
50+ id: "cartographer", name: "Cartographer", appearance: .light,
51+ palette: AtlasPalette(
52+ background: 0xF7F1E6, surface: 0xFFFBF3, surfaceSecondary: 0xEFE7D6,
53+ accent: 0xB5742B, accentIndigo: 0x3F6B7D, accentSubtle: 0xF0E4CD,
54+ textPrimary: 0x2C2418, textSecondary: 0x6E6350, textTertiary: 0x9B8F79,
55+ border: 0xE3D8C2, success: 0x5C8A4A, warning: 0xC57A1E, danger: 0xBB4A3A),
56+ background: BackgroundStyle(kind: .gradient, colorA: 0xF7F1E6, colorB: 0xEFE4CE))
57+
58+ static let meridian = AtlasTheme(
59+ id: "meridian", name: "Meridian", appearance: .light,
60+ palette: AtlasPalette(
61+ background: 0xF7FAFD, surface: 0xFFFFFF, surfaceSecondary: 0xEDF3FA,
62+ accent: 0x2D6FE0, accentIndigo: 0x5B4FE0, accentSubtle: 0xE2ECFB,
63+ textPrimary: 0x172233, textSecondary: 0x5E6B7E, textTertiary: 0x94A0B2,
64+ border: 0xDCE6F1, success: 0x2FA36B, warning: 0xD9822B, danger: 0xD64545),
65+ background: BackgroundStyle(kind: .solid, colorA: 0xF7FAFD))
66+
67+ static let terra = AtlasTheme(
68+ id: "terra", name: "Terra", appearance: .light,
69+ palette: AtlasPalette(
70+ background: 0xF6F9F3, surface: 0xFFFFFF, surfaceSecondary: 0xEAF1E4,
71+ accent: 0x3E8E5A, accentIndigo: 0x4E63E0, accentSubtle: 0xE1F0E5,
72+ textPrimary: 0x1B2419, textSecondary: 0x5D6B58, textTertiary: 0x93A08C,
73+ border: 0xDDE8D6, success: 0x2FA36B, warning: 0xD9822B, danger: 0xD64545),
74+ background: BackgroundStyle(kind: .solid, colorA: 0xF6F9F3))
75+
76+ static let slate = AtlasTheme(
77+ id: "slate", name: "Slate", appearance: .light,
78+ palette: AtlasPalette(
79+ background: 0xF4F5F7, surface: 0xFFFFFF, surfaceSecondary: 0xEAECEF,
80+ accent: 0x4B5563, accentIndigo: 0x6366F1, accentSubtle: 0xE7E9ED,
81+ textPrimary: 0x1B1F27, textSecondary: 0x646B78, textTertiary: 0x99A0AD,
82+ border: 0xE0E3E8, success: 0x2FA36B, warning: 0xD9822B, danger: 0xD64545),
83+ background: BackgroundStyle(kind: .solid, colorA: 0xF4F5F7))
84+
85+ static let roseQuartz = AtlasTheme(
86+ id: "rose-quartz", name: "Rose Quartz", appearance: .light,
87+ palette: AtlasPalette(
88+ background: 0xFCF5F7, surface: 0xFFFFFF, surfaceSecondary: 0xF7E9EE,
89+ accent: 0xC64B7B, accentIndigo: 0x7C5CE0, accentSubtle: 0xF9E3EC,
90+ textPrimary: 0x2A1C22, textSecondary: 0x74606A, textTertiary: 0xA892A0,
91+ border: 0xEFD9E1, success: 0x2FA36B, warning: 0xD9822B, danger: 0xD64545),
92+ background: BackgroundStyle(kind: .gradient, colorA: 0xFCF5F7, colorB: 0xF7E6EE))
93+
94+ // MARK: - Dark themes
95+
96+ static let midnight = AtlasTheme(
97+ id: "midnight", name: "Midnight Atlas", appearance: .dark,
98+ palette: AtlasPalette(
99+ background: 0x0F1524, surface: 0x18203A, surfaceSecondary: 0x212C4A,
100+ accent: 0x5C7CFA, accentIndigo: 0x8B6DF0, accentSubtle: 0x22305C,
101+ textPrimary: 0xE7ECF7, textSecondary: 0x96A0BC, textTertiary: 0x646E8C,
102+ border: 0x2A3557, success: 0x43BD83, warning: 0xE59A4D, danger: 0xE36363),
103+ background: BackgroundStyle(kind: .gradient, colorA: 0x0F1524, colorB: 0x161E38))
104+
105+ static let nocturne = AtlasTheme(
106+ id: "nocturne", name: "Nocturne", appearance: .dark,
107+ palette: AtlasPalette(
108+ background: 0x000000, surface: 0x0E0E10, surfaceSecondary: 0x18181B,
109+ accent: 0x2DD4BF, accentIndigo: 0x818CF8, accentSubtle: 0x0E2A28,
110+ textPrimary: 0xF4F4F5, textSecondary: 0x9BA0A8, textTertiary: 0x5C6069,
111+ border: 0x232327, success: 0x43BD83, warning: 0xE59A4D, danger: 0xE36363),
112+ background: BackgroundStyle(kind: .solid, colorA: 0x000000))
113+
114+ static let aurora = AtlasTheme(
115+ id: "aurora", name: "Aurora", appearance: .dark,
116+ palette: AtlasPalette(
117+ background: 0x10141A, surface: 0x171D26, surfaceSecondary: 0x1F2833,
118+ accent: 0x36D399, accentIndigo: 0xA78BFA, accentSubtle: 0x123028,
119+ textPrimary: 0xE9F1EE, textSecondary: 0x93A2A0, textTertiary: 0x5F6E6C,
120+ border: 0x27313D, success: 0x36D399, warning: 0xE5B04D, danger: 0xE36363),
121+ background: BackgroundStyle(kind: .gradient, colorA: 0x10141A, colorB: 0x14201C))
122+}
added Sources/ZyquoAtlas/DesignSystem/ThemeEngine.swift +175 −0
@@ -0,0 +1,175 @@
1+//
2+// ThemeEngine.swift
3+// Zyquo Atlas
4+//
5+// Author: Simon-Pierre Boucher
6+// Mail: contact@spboucher.ai
7+//
8+// The customization engine (Phase 4.2 headline). Publishes the active theme,
9+// layout, and any user custom themes; every change applies live (SwiftUI
10+// re-renders because views observe this object). Persists per profile as JSON
11+// under the Atlas data root, imports/exports themes as small JSON files, and
12+// can follow the system light/dark appearance.
13+//
14+
15+import SwiftUI
16+import Combine
17+
18+@MainActor
19+final class ThemeEngine: ObservableObject {
20+ /// The active resolved theme (built-in or custom).
21+ @Published private(set) var theme: AtlasTheme
22+ /// Layout/typography preferences (top/left tabs, density, zoom…).
23+ @Published var layout: LayoutSettings
24+ /// User-created themes (editable, exportable).
25+ @Published private(set) var customThemes: [AtlasTheme]
26+ /// When true, the engine swaps between `lightThemeID`/`darkThemeID` to match
27+ /// the system appearance.
28+ @Published var matchSystemAppearance: Bool
29+ @Published var selectedThemeID: String
30+ @Published var lightThemeID: String
31+ @Published var darkThemeID: String
32+
33+ private let storeURL: URL
34+ private var saveTask: Task<Void, Never>?
35+
36+ init(profile: Profile = .defaultProfile) {
37+ let dir = PersistenceService.shared.rootDirectory
38+ storeURL = dir.appendingPathComponent("theme-\(profile.id.uuidString).json")
39+
40+ // Defaults.
41+ var restored = Persisted()
42+ if let data = try? Data(contentsOf: storeURL),
43+ let decoded = try? JSONDecoder().decode(Persisted.self, from: data) {
44+ restored = decoded
45+ }
46+ layout = restored.layout
47+ customThemes = restored.customThemes
48+ matchSystemAppearance = restored.matchSystemAppearance
49+ selectedThemeID = restored.selectedThemeID
50+ lightThemeID = restored.lightThemeID
51+ darkThemeID = restored.darkThemeID
52+
53+ let all = BuiltInThemes.all + restored.customThemes
54+ theme = all.first { $0.id == restored.selectedThemeID } ?? BuiltInThemes.atlasLight
55+ resolveActive()
56+ }
57+
58+ // MARK: - Catalog
59+
60+ var gallery: [AtlasTheme] { BuiltInThemes.all + customThemes }
61+
62+ func theme(id: String) -> AtlasTheme? { gallery.first { $0.id == id } }
63+
64+ // MARK: - Selection
65+
66+ func select(_ id: String) {
67+ selectedThemeID = id
68+ if let t = theme(id: id) {
69+ if t.appearance == .dark { darkThemeID = id } else { lightThemeID = id }
70+ }
71+ matchSystemAppearance = false
72+ resolveActive()
73+ persist()
74+ }
75+
76+ /// Called when the system appearance changes (from the view layer).
77+ func systemAppearanceChanged(toDark: Bool) {
78+ guard matchSystemAppearance else { return }
79+ selectedThemeID = toDark ? darkThemeID : lightThemeID
80+ resolveActive()
81+ }
82+
83+ func setMatchSystem(_ on: Bool, systemIsDark: Bool) {
84+ matchSystemAppearance = on
85+ if on { selectedThemeID = systemIsDark ? darkThemeID : lightThemeID }
86+ resolveActive()
87+ persist()
88+ }
89+
90+ // MARK: - Layout
91+
92+ func updateLayout(_ mutate: (inout LayoutSettings) -> Void) {
93+ mutate(&layout)
94+ persist()
95+ }
96+
97+ // MARK: - Custom themes
98+
99+ /// Creates an editable copy of a theme and selects it.
100+ @discardableResult
101+ func duplicateForEditing(_ base: AtlasTheme, name: String) -> AtlasTheme {
102+ var copy = base
103+ copy.id = "custom-\(UUID().uuidString.prefix(8))"
104+ copy.name = name
105+ customThemes.append(copy)
106+ select(copy.id)
107+ return copy
108+ }
109+
110+ /// Updates an existing custom theme in place (live editor).
111+ func updateCustom(_ theme: AtlasTheme) {
112+ guard let idx = customThemes.firstIndex(where: { $0.id == theme.id }) else { return }
113+ customThemes[idx] = theme
114+ if selectedThemeID == theme.id { self.theme = theme }
115+ persist()
116+ }
117+
118+ func deleteCustom(_ id: String) {
119+ customThemes.removeAll { $0.id == id }
120+ if selectedThemeID == id { select(BuiltInThemes.defaultLightID) }
121+ persist()
122+ }
123+
124+ // MARK: - Import / export
125+
126+ func exportTheme(_ theme: AtlasTheme, to url: URL) throws {
127+ let encoder = JSONEncoder()
128+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
129+ try encoder.encode(theme).write(to: url, options: .atomic)
130+ }
131+
132+ @discardableResult
133+ func importTheme(from url: URL) throws -> AtlasTheme {
134+ var t = try JSONDecoder().decode(AtlasTheme.self, from: Data(contentsOf: url))
135+ // Give imported themes a fresh custom id to avoid collisions.
136+ if BuiltInThemes.theme(id: t.id) != nil || customThemes.contains(where: { $0.id == t.id }) {
137+ t.id = "custom-\(UUID().uuidString.prefix(8))"
138+ }
139+ customThemes.append(t)
140+ select(t.id)
141+ return t
142+ }
143+
144+ // MARK: - Internals
145+
146+ private func resolveActive() {
147+ theme = theme(id: selectedThemeID) ?? BuiltInThemes.atlasLight
148+ }
149+
150+ private func persist() {
151+ let snapshot = Persisted(
152+ layout: layout, customThemes: customThemes,
153+ matchSystemAppearance: matchSystemAppearance,
154+ selectedThemeID: selectedThemeID,
155+ lightThemeID: lightThemeID, darkThemeID: darkThemeID)
156+ let url = storeURL
157+ saveTask?.cancel()
158+ saveTask = Task.detached(priority: .utility) {
159+ let encoder = JSONEncoder()
160+ encoder.outputFormatting = [.sortedKeys]
161+ if let data = try? encoder.encode(snapshot) {
162+ try? data.write(to: url, options: .atomic)
163+ }
164+ }
165+ }
166+
167+ private struct Persisted: Codable {
168+ var layout = LayoutSettings()
169+ var customThemes: [AtlasTheme] = []
170+ var matchSystemAppearance = true
171+ var selectedThemeID = BuiltInThemes.defaultLightID
172+ var lightThemeID = BuiltInThemes.defaultLightID
173+ var darkThemeID = BuiltInThemes.defaultDarkID
174+ }
175+}
modified Sources/ZyquoAtlas/DesignSystem/ZyquoTheme.swift +5 −41
@@ -5,51 +5,15 @@
5 // Author: Simon-Pierre Boucher 5 // Author: Simon-Pierre Boucher
6 // Mail: contact@spboucher.ai 6 // Mail: contact@spboucher.ai
7 // 7 //
8−// The design system. Every color, font, spacing, radius, and shadow comes from 8+// The non-color design tokens: typography, spacing, radii, shadows, and layout
9−// these base tokens — views contain zero raw hex values or magic numbers. The 9+// metrics — views contain zero magic numbers. Colors live in AtlasTheme /
10−// light theme is the flagship (Phase 4.1 spec); dark derives from the same 10+// BuiltInThemes and are applied live by ThemeEngine (the flagship "Atlas Light"
11−// semantic tokens. Atlas's identity is a balanced teal-indigo "map/atlas" 11+// theme is the Phase 4.1 base spec); fonts scale with the user's uiScale via
12−// story. In Phase 4 the ThemeEngine overrides these base values with user 12+// LayoutSettings. Atlas's identity is a balanced teal-indigo "map/atlas" story.
13−// themes; until then these are resolved directly.
14 // 13 //
15 14
16 import SwiftUI 15 import SwiftUI
17 16
18−// MARK: - Colors
19−
20−/// Semantic base color tokens (Phase 4.1). Resolved per appearance via dynamic
21−/// NSColor so the system handles light/dark switching natively.
22−enum ZyquoColor {
23− /// Chrome / canvas — crisp cool off-white / deep atlas navy.
24− static let background = dynamic(light: 0xFAFBFC, dark: 0x14171C)
25− /// Toolbars, panels, cards.
26− static let surface = dynamic(light: 0xFFFFFF, dark: 0x1C2029)
27− /// Hover, inactive tabs.
28− static let surfaceSecondary = dynamic(light: 0xF1F4F6, dark: 0x252A34)
29− /// Active tab, selection, AI actions, omnibox focus (atlas teal).
30− static let accent = dynamic(light: 0x1FA9A0, dark: 0x35C3B8)
31− /// Paired indigo — AI surfaces, secondary accent (teal-indigo story).
32− static let accentIndigo = dynamic(light: 0x4E63E0, dark: 0x6E80EE)
33− /// Active tab tint, selected rows.
34− static let accentSubtle = dynamic(light: 0xE6F5F3, dark: 0x1B3330)
35− static let textPrimary = dynamic(light: 0x1A1D22, dark: 0xE8EBF0)
36− static let textSecondary = dynamic(light: 0x6B7280, dark: 0x9AA1AE)
37− static let textTertiary = dynamic(light: 0x9CA3AF, dark: 0x69707E)
38− /// Hairline separators (draw at 0.5pt).
39− static let border = dynamic(light: 0xE5E9EC, dark: 0x2B303A)
40− static let success = dynamic(light: 0x2FA36B, dark: 0x43BD83)
41− static let warning = dynamic(light: 0xD9822B, dark: 0xE59A4D)
42− static let danger = dynamic(light: 0xD64545, dark: 0xE36363)
43−
44− /// Builds a dynamic color that resolves per appearance.
45− private static func dynamic(light: UInt32, dark: UInt32) -> Color {
46− Color(nsColor: NSColor(name: nil) { appearance in
47− let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light
48− return NSColor(hex: hex)
49− })
50− }
51−}
52−
53 extension NSColor { 17 extension NSColor {
54 /// 0xRRGGBB → NSColor (sRGB). 18 /// 0xRRGGBB → NSColor (sRGB).
55 convenience init(hex: UInt32) { 19 convenience init(hex: UInt32) {
modified Sources/ZyquoAtlas/Views/AI/AISidebarView.swift +17 −15
@@ -18,21 +18,23 @@ struct AISidebarView: View {
18 @ObservedObject var tab: Tab 18 @ObservedObject var tab: Tab
19 @ObservedObject var ai: AIService 19 @ObservedObject var ai: AIService
20 let env: AppEnvironment 20 let env: AppEnvironment
21+ @EnvironmentObject private var themeEngine: ThemeEngine
22+ private var theme: AtlasTheme { themeEngine.theme }
21 23
22 @State private var extractionError: String? 24 @State private var extractionError: String?
23 25
24 var body: some View { 26 var body: some View {
25 VStack(alignment: .leading, spacing: 0) { 27 VStack(alignment: .leading, spacing: 0) {
26 header 28 header
27− Divider().overlay(ZyquoColor.border) 29+ Divider().overlay(theme.border)
28 actionBar 30 actionBar
29− Divider().overlay(ZyquoColor.border) 31+ Divider().overlay(theme.border)
30 answer 32 answer
31 } 33 }
32 .frame(width: ZyquoMetrics.aiSidebarWidth) 34 .frame(width: ZyquoMetrics.aiSidebarWidth)
33− .background(ZyquoColor.surface) 35+ .background(theme.surface)
34 .overlay(alignment: .leading) { 36 .overlay(alignment: .leading) {
35− Rectangle().fill(ZyquoColor.border).frame(width: ZyquoMetrics.hairline) 37+ Rectangle().fill(theme.border).frame(width: ZyquoMetrics.hairline)
36 } 38 }
37 } 39 }
38 40
@@ -41,17 +43,17 @@ struct AISidebarView: View {
41 private var header: some View { 43 private var header: some View {
42 HStack(spacing: ZyquoSpacing.xs) { 44 HStack(spacing: ZyquoSpacing.xs) {
43 Image(systemName: "sparkles") 45 Image(systemName: "sparkles")
44− .foregroundStyle(ZyquoColor.accentIndigo) 46+ .foregroundStyle(theme.accentIndigo)
45 Text("Atlas AI") 47 Text("Atlas AI")
46 .font(ZyquoFont.bodyEmphasis()) 48 .font(ZyquoFont.bodyEmphasis())
47− .foregroundStyle(ZyquoColor.textPrimary) 49+ .foregroundStyle(theme.textPrimary)
48 Spacer() 50 Spacer()
49 Text(env.defaultModel?.displayName ?? "No model") 51 Text(env.defaultModel?.displayName ?? "No model")
50 .font(ZyquoFont.caption) 52 .font(ZyquoFont.caption)
51− .foregroundStyle(ZyquoColor.textSecondary) 53+ .foregroundStyle(theme.textSecondary)
52 .padding(.horizontal, ZyquoSpacing.xs) 54 .padding(.horizontal, ZyquoSpacing.xs)
53 .padding(.vertical, 2) 55 .padding(.vertical, 2)
54− .background(RoundedRectangle(cornerRadius: ZyquoRadius.small).fill(ZyquoColor.accentSubtle)) 56+ .background(RoundedRectangle(cornerRadius: ZyquoRadius.small).fill(theme.accentSubtle))
55 } 57 }
56 .padding(ZyquoSpacing.sm) 58 .padding(ZyquoSpacing.sm)
57 } 59 }
@@ -62,7 +64,7 @@ struct AISidebarView: View {
62 if ai.isStreaming { 64 if ai.isStreaming {
63 Button("Stop") { ai.cancel() } 65 Button("Stop") { ai.cancel() }
64 .font(ZyquoFont.caption) 66 .font(ZyquoFont.caption)
65− .foregroundStyle(ZyquoColor.danger) 67+ .foregroundStyle(theme.danger)
66 } 68 }
67 Spacer() 69 Spacer()
68 } 70 }
@@ -76,22 +78,22 @@ struct AISidebarView: View {
76 if let note = ai.statusNote { 78 if let note = ai.statusNote {
77 Label(note, systemImage: "arrow.triangle.2.circlepath") 79 Label(note, systemImage: "arrow.triangle.2.circlepath")
78 .font(ZyquoFont.caption) 80 .font(ZyquoFont.caption)
79− .foregroundStyle(ZyquoColor.textSecondary) 81+ .foregroundStyle(theme.textSecondary)
80 } 82 }
81 if let err = extractionError ?? ai.errorText { 83 if let err = extractionError ?? ai.errorText {
82 Label(err, systemImage: "exclamationmark.triangle") 84 Label(err, systemImage: "exclamationmark.triangle")
83 .font(ZyquoFont.caption) 85 .font(ZyquoFont.caption)
84− .foregroundStyle(ZyquoColor.danger) 86+ .foregroundStyle(theme.danger)
85 } 87 }
86 if !ai.output.isEmpty { 88 if !ai.output.isEmpty {
87 Text(ai.output) 89 Text(ai.output)
88 .font(ZyquoFont.body()) 90 .font(ZyquoFont.body())
89− .foregroundStyle(ZyquoColor.textPrimary) 91+ .foregroundStyle(theme.textPrimary)
90 .textSelection(.enabled) 92 .textSelection(.enabled)
91 } else if !ai.isStreaming && extractionError == nil { 93 } else if !ai.isStreaming && extractionError == nil {
92 Text("Ask about this page or summarize it. Page content is sent to your chosen provider only when you run an action.") 94 Text("Ask about this page or summarize it. Page content is sent to your chosen provider only when you run an action.")
93 .font(ZyquoFont.caption) 95 .font(ZyquoFont.caption)
94− .foregroundStyle(ZyquoColor.textTertiary) 96+ .foregroundStyle(theme.textTertiary)
95 } 97 }
96 } 98 }
97 .frame(maxWidth: .infinity, alignment: .leading) 99 .frame(maxWidth: .infinity, alignment: .leading)
@@ -109,8 +111,8 @@ struct AISidebarView: View {
109 .padding(.vertical, ZyquoSpacing.xxs) 111 .padding(.vertical, ZyquoSpacing.xxs)
110 } 112 }
111 .buttonStyle(.plain) 113 .buttonStyle(.plain)
112− .foregroundStyle(ZyquoColor.accent) 114+ .foregroundStyle(theme.accent)
113− .background(RoundedRectangle(cornerRadius: ZyquoRadius.small).fill(ZyquoColor.accentSubtle)) 115+ .background(RoundedRectangle(cornerRadius: ZyquoRadius.small).fill(theme.accentSubtle))
114 .disabled(ai.isStreaming) 116 .disabled(ai.isStreaming)
115 } 117 }
116 118
modified Sources/ZyquoAtlas/Views/Browser/BrowserWindowView.swift +54 −16
@@ -5,10 +5,10 @@
5 // Author: Simon-Pierre Boucher 5 // Author: Simon-Pierre Boucher
6 // Mail: contact@spboucher.ai 6 // Mail: contact@spboucher.ai
7 // 7 //
8−// The browser window root: tab strip on top, toolbar (omnibox + nav + 8+// The browser window root. Composes the chrome — tab strip (top-horizontal OR
9−// progress), then the active tab's web content. Owns the window's TabManager. 9+// left-vertical per the live layout setting), toolbar (omnibox + nav +
10−// Replaces the Phase 1 placeholder. The AI sidebar, bookmarks bar, and 10+// progress), web content, and the AI sidebar — all themed live by ThemeEngine.
11−// vertical-tab layout attach around this in later phases. 11+// Owns the window's TabManager. The customization panel opens as a sheet.
12 // 12 //
13 13
14 import SwiftUI 14 import SwiftUI
@@ -16,36 +16,74 @@ import SwiftUI
16 struct BrowserWindowView: View { 16 struct BrowserWindowView: View {
17 @StateObject private var tabManager = TabManager(profile: .defaultProfile) 17 @StateObject private var tabManager = TabManager(profile: .defaultProfile)
18 @EnvironmentObject private var env: AppEnvironment 18 @EnvironmentObject private var env: AppEnvironment
19+ @EnvironmentObject private var themeEngine: ThemeEngine
19 @State private var showAISidebar = false 20 @State private var showAISidebar = false
21+ @State private var showCustomize = false
22+
23+ private var theme: AtlasTheme { themeEngine.theme }
24+ private var layout: LayoutSettings { themeEngine.layout }
20 25
21 var body: some View { 26 var body: some View {
22− VStack(spacing: 0) { 27+ Group {
23− TabBarView(tabManager: tabManager) 28+ if layout.tabPosition == .left {
29+ HStack(spacing: 0) {
30+ VerticalTabBarView(tabManager: tabManager, onNewTab: { tabManager.newTab() })
31+ mainColumn
32+ }
33+ } else {
34+ VStack(spacing: 0) {
35+ TabBarView(tabManager: tabManager)
36+ mainColumn
37+ }
38+ }
39+ }
40+ .background(theme.backgroundColor)
41+ .frame(minWidth: 900, minHeight: 600)
42+ .animation(.easeInOut(duration: 0.15), value: showAISidebar)
43+ .animation(.easeInOut(duration: 0.18), value: layout.tabPosition)
44+ .onAppear { if tabManager.tabs.isEmpty { tabManager.newTab() } }
45+ .sheet(isPresented: $showCustomize) {
46+ CustomizationView().environmentObject(themeEngine)
47+ }
48+ }
24 49
25− if let tab = tabManager.activeTab { 50+ // MARK: - Main column (toolbar + content + AI sidebar)
51+
52+ @ViewBuilder
53+ private var mainColumn: some View {
54+ if let tab = tabManager.activeTab {
55+ VStack(spacing: 0) {
26 ToolbarView( 56 ToolbarView(
27 tab: tab, 57 tab: tab,
28 isAISidebarOpen: showAISidebar, 58 isAISidebarOpen: showAISidebar,
29 onSubmit: { tabManager.loadInActiveTab($0) }, 59 onSubmit: { tabManager.loadInActiveTab($0) },
30 onNewTab: { tabManager.newTab() }, 60 onNewTab: { tabManager.newTab() },
31− onToggleAI: { showAISidebar.toggle() } 61+ onToggleAI: { showAISidebar.toggle() },
62+ onCustomize: { showCustomize = true }
32 ) 63 )
33 HStack(spacing: 0) { 64 HStack(spacing: 0) {
34− WebContentArea(tab: tab) 65+ contentArea(for: tab)
35 if showAISidebar { 66 if showAISidebar {
36 AISidebarView(tab: tab, ai: tab.ai, env: env) 67 AISidebarView(tab: tab, ai: tab.ai, env: env)
37 .transition(.move(edge: .trailing)) 68 .transition(.move(edge: .trailing))
38 } 69 }
39 } 70 }
40− } else {
41− Spacer()
42 } 71 }
72+ } else {
73+ Spacer()
43 } 74 }
44− .background(ZyquoColor.background) 75+ }
45− .frame(minWidth: 900, minHeight: 600) 76+
46− .animation(.easeInOut(duration: 0.15), value: showAISidebar) 77+ /// Shows the customizable start page on a blank tab, else the web content.
47− .onAppear { 78+ @ViewBuilder
48− if tabManager.tabs.isEmpty { tabManager.newTab() } 79+ private func contentArea(for tab: Tab) -> some View {
80+ if tab.url == nil || tab.url == TabManager.homeURL {
81+ StartPageView(
82+ onNavigate: { tabManager.loadInActiveTab($0) },
83+ onAsk: { showAISidebar = true }
84+ )
85+ } else {
86+ WebContentArea(tab: tab)
49 } 87 }
50 } 88 }
51 } 89 }
modified Sources/ZyquoAtlas/Views/Browser/OmniboxView.swift +8 −6
@@ -15,6 +15,8 @@ import SwiftUI
15 struct OmniboxView: View { 15 struct OmniboxView: View {
16 @ObservedObject var tab: Tab 16 @ObservedObject var tab: Tab
17 let onSubmit: (String) -> Void 17 let onSubmit: (String) -> Void
18+ @EnvironmentObject private var themeEngine: ThemeEngine
19+ private var theme: AtlasTheme { themeEngine.theme }
18 20
19 @State private var text: String = "" 21 @State private var text: String = ""
20 @State private var isEditing: Bool = false 22 @State private var isEditing: Bool = false
@@ -29,7 +31,7 @@ struct OmniboxView: View {
29 TextField("Search or enter website name", text: $text) 31 TextField("Search or enter website name", text: $text)
30 .textFieldStyle(.plain) 32 .textFieldStyle(.plain)
31 .font(ZyquoFont.control) 33 .font(ZyquoFont.control)
32− .foregroundStyle(ZyquoColor.textPrimary) 34+ .foregroundStyle(theme.textPrimary)
33 .focused($focused) 35 .focused($focused)
34 .onSubmit { 36 .onSubmit {
35 onSubmit(text) 37 onSubmit(text)
@@ -46,7 +48,7 @@ struct OmniboxView: View {
46 } label: { 48 } label: {
47 Image(systemName: "xmark.circle.fill") 49 Image(systemName: "xmark.circle.fill")
48 .font(.system(size: 12)) 50 .font(.system(size: 12))
49− .foregroundStyle(ZyquoColor.textTertiary) 51+ .foregroundStyle(theme.textTertiary)
50 } 52 }
51 .buttonStyle(.plain) 53 .buttonStyle(.plain)
52 } 54 }
@@ -55,11 +57,11 @@ struct OmniboxView: View {
55 .frame(height: 30) 57 .frame(height: 30)
56 .background( 58 .background(
57 RoundedRectangle(cornerRadius: ZyquoRadius.small) 59 RoundedRectangle(cornerRadius: ZyquoRadius.small)
58− .fill(ZyquoColor.surfaceSecondary) 60+ .fill(theme.surfaceSecondary)
59 ) 61 )
60 .overlay( 62 .overlay(
61 RoundedRectangle(cornerRadius: ZyquoRadius.small) 63 RoundedRectangle(cornerRadius: ZyquoRadius.small)
62− .strokeBorder(focused ? ZyquoColor.accent : ZyquoColor.border, 64+ .strokeBorder(focused ? theme.accent : theme.border,
63 lineWidth: focused ? 1.5 : ZyquoMetrics.hairline) 65 lineWidth: focused ? 1.5 : ZyquoMetrics.hairline)
64 ) 66 )
65 .onChange(of: tab.url) { _ in if !isEditing { syncFromTab() } } 67 .onChange(of: tab.url) { _ in if !isEditing { syncFromTab() } }
@@ -74,8 +76,8 @@ struct OmniboxView: View {
74 } 76 }
75 77
76 private var securityColor: Color { 78 private var securityColor: Color {
77− guard tab.url != nil else { return ZyquoColor.textTertiary } 79+ guard tab.url != nil else { return theme.textTertiary }
78− return tab.hasSecureConnection ? ZyquoColor.textSecondary : ZyquoColor.warning 80+ return tab.hasSecureConnection ? theme.textSecondary : theme.warning
79 } 81 }
80 82
81 private func syncFromTab() { 83 private func syncFromTab() {
added Sources/ZyquoAtlas/Views/Browser/StartPageView.swift +123 −0
@@ -0,0 +1,123 @@
1+//
2+// StartPageView.swift
3+// Zyquo Atlas
4+//
5+// Author: Simon-Pierre Boucher
6+// Mail: contact@spboucher.ai
7+//
8+// The customizable new-tab / start page (Phase 4.2). Shows the theme
9+// background, a greeting, a prominent AI-or-search ask box, and a favorites
10+// quick grid. Bookmarks/history widgets and per-user layout of these sections
11+// fill in with the bookmark & history services in Phase 6.
12+//
13+
14+import SwiftUI
15+
16+struct StartPageView: View {
17+ let onNavigate: (String) -> Void
18+ let onAsk: () -> Void
19+ @EnvironmentObject private var themeEngine: ThemeEngine
20+ private var theme: AtlasTheme { themeEngine.theme }
21+
22+ @State private var query = ""
23+
24+ // Placeholder favorites until BookmarksService (Phase 6).
25+ private let quickLinks: [(String, String)] = [
26+ ("Wikipedia", "https://en.wikipedia.org"),
27+ ("DuckDuckGo", "https://duckduckgo.com"),
28+ ("GitHub", "https://github.com"),
29+ ("Hacker News", "https://news.ycombinator.com"),
30+ ("MDN", "https://developer.mozilla.org"),
31+ ("arXiv", "https://arxiv.org"),
32+ ]
33+
34+ var body: some View {
35+ ZStack {
36+ theme.backgroundView.ignoresSafeArea()
37+
38+ VStack(spacing: ZyquoSpacing.xl) {
39+ Spacer()
40+
41+ VStack(spacing: ZyquoSpacing.xs) {
42+ Text("Zyquo Atlas")
43+ .font(themeEngine.layout.font(34, weight: .bold))
44+ .foregroundStyle(theme.textPrimary)
45+ Text("Explore the web, with AI everywhere.")
46+ .font(themeEngine.layout.font(14))
47+ .foregroundStyle(theme.textSecondary)
48+ }
49+
50+ askBox
51+ .frame(maxWidth: 620)
52+
53+ favoritesGrid
54+ .frame(maxWidth: 620)
55+
56+ Spacer()
57+ Spacer()
58+ }
59+ .padding(ZyquoSpacing.xxl)
60+ }
61+ }
62+
63+ private var askBox: some View {
64+ HStack(spacing: ZyquoSpacing.sm) {
65+ Image(systemName: "magnifyingglass")
66+ .foregroundStyle(theme.textTertiary)
67+ TextField("Search, enter a URL, or ask AI…", text: $query)
68+ .textFieldStyle(.plain)
69+ .font(themeEngine.layout.font(15))
70+ .foregroundStyle(theme.textPrimary)
71+ .onSubmit {
72+ let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
73+ guard !q.isEmpty else { return }
74+ onNavigate(q); query = ""
75+ }
76+ Button(action: onAsk) {
77+ Label("Ask AI", systemImage: "sparkles")
78+ .font(themeEngine.layout.font(12, weight: .medium))
79+ .padding(.horizontal, ZyquoSpacing.sm)
80+ .padding(.vertical, ZyquoSpacing.xxs)
81+ .background(RoundedRectangle(cornerRadius: ZyquoRadius.small).fill(theme.accentSubtle))
82+ .foregroundStyle(theme.accentIndigo)
83+ }
84+ .buttonStyle(.plain)
85+ }
86+ .padding(.horizontal, ZyquoSpacing.md)
87+ .frame(height: 52)
88+ .background(
89+ RoundedRectangle(cornerRadius: ZyquoRadius.large)
90+ .fill(theme.surface)
91+ .shadow(color: ZyquoShadow.soft.color, radius: ZyquoShadow.soft.radius,
92+ y: ZyquoShadow.soft.y)
93+ )
94+ .overlay(
95+ RoundedRectangle(cornerRadius: ZyquoRadius.large)
96+ .strokeBorder(theme.border, lineWidth: ZyquoMetrics.hairline)
97+ )
98+ }
99+
100+ private var favoritesGrid: some View {
101+ LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: ZyquoSpacing.sm), count: 3),
102+ spacing: ZyquoSpacing.sm) {
103+ ForEach(quickLinks, id: \.0) { name, url in
104+ Button { onNavigate(url) } label: {
105+ HStack(spacing: ZyquoSpacing.xs) {
106+ Image(systemName: "globe").foregroundStyle(theme.accent)
107+ Text(name)
108+ .font(themeEngine.layout.font(12.5, weight: .medium))
109+ .foregroundStyle(theme.textPrimary)
110+ .lineLimit(1)
111+ Spacer(minLength: 0)
112+ }
113+ .padding(.horizontal, ZyquoSpacing.sm)
114+ .frame(height: 40)
115+ .background(RoundedRectangle(cornerRadius: ZyquoRadius.medium).fill(theme.surface))
116+ .overlay(RoundedRectangle(cornerRadius: ZyquoRadius.medium)
117+ .strokeBorder(theme.border, lineWidth: ZyquoMetrics.hairline))
118+ }
119+ .buttonStyle(.plain)
120+ }
121+ }
122+ }
123+}
modified Sources/ZyquoAtlas/Views/Browser/TabBarView.swift +11 −7
@@ -15,6 +15,8 @@ import SwiftUI
15 15
16 struct TabBarView: View { 16 struct TabBarView: View {
17 @ObservedObject var tabManager: TabManager 17 @ObservedObject var tabManager: TabManager
18+ @EnvironmentObject private var themeEngine: ThemeEngine
19+ private var theme: AtlasTheme { themeEngine.theme }
18 20
19 var body: some View { 21 var body: some View {
20 ScrollView(.horizontal, showsIndicators: false) { 22 ScrollView(.horizontal, showsIndicators: false) {
@@ -31,7 +33,7 @@ struct TabBarView: View {
31 .padding(.horizontal, ZyquoSpacing.xs) 33 .padding(.horizontal, ZyquoSpacing.xs)
32 } 34 }
33 .frame(height: ZyquoMetrics.tabBarHeight) 35 .frame(height: ZyquoMetrics.tabBarHeight)
34− .background(ZyquoColor.background) 36+ .background(theme.backgroundColor)
35 } 37 }
36 } 38 }
37 39
@@ -40,6 +42,8 @@ private struct TabChip: View {
40 let isActive: Bool 42 let isActive: Bool
41 let onSelect: () -> Void 43 let onSelect: () -> Void
42 let onClose: () -> Void 44 let onClose: () -> Void
45+ @EnvironmentObject private var themeEngine: ThemeEngine
46+ private var theme: AtlasTheme { themeEngine.theme }
43 47
44 @State private var hovering = false 48 @State private var hovering = false
45 49
@@ -51,7 +55,7 @@ private struct TabChip: View {
51 Text(tab.title) 55 Text(tab.title)
52 .font(ZyquoFont.control) 56 .font(ZyquoFont.control)
53 .lineLimit(1) 57 .lineLimit(1)
54− .foregroundStyle(isActive ? ZyquoColor.textPrimary : ZyquoColor.textSecondary) 58+ .foregroundStyle(isActive ? theme.textPrimary : theme.textSecondary)
55 59
56 Spacer(minLength: 0) 60 Spacer(minLength: 0)
57 61
@@ -63,19 +67,19 @@ private struct TabChip: View {
63 .contentShape(Rectangle()) 67 .contentShape(Rectangle())
64 } 68 }
65 .buttonStyle(.plain) 69 .buttonStyle(.plain)
66− .foregroundStyle(ZyquoColor.textTertiary) 70+ .foregroundStyle(theme.textTertiary)
67 } 71 }
68 } 72 }
69 .padding(.horizontal, ZyquoSpacing.sm) 73 .padding(.horizontal, ZyquoSpacing.sm)
70 .frame(width: ZyquoMetrics.tabMinWidth, height: ZyquoMetrics.tabBarHeight - 6) 74 .frame(width: ZyquoMetrics.tabMinWidth, height: ZyquoMetrics.tabBarHeight - 6)
71 .background( 75 .background(
72 RoundedRectangle(cornerRadius: ZyquoRadius.small) 76 RoundedRectangle(cornerRadius: ZyquoRadius.small)
73− .fill(isActive ? ZyquoColor.accentSubtle 77+ .fill(isActive ? theme.accentSubtle
74− : (hovering ? ZyquoColor.surfaceSecondary : .clear)) 78+ : (hovering ? theme.surfaceSecondary : .clear))
75 ) 79 )
76 .overlay(alignment: .bottom) { 80 .overlay(alignment: .bottom) {
77 if isActive { 81 if isActive {
78− Rectangle().fill(ZyquoColor.accent).frame(height: 2) 82+ Rectangle().fill(theme.accent).frame(height: 2)
79 .padding(.horizontal, ZyquoSpacing.xs) 83 .padding(.horizontal, ZyquoSpacing.xs)
80 } 84 }
81 } 85 }
@@ -93,7 +97,7 @@ private struct TabChip: View {
93 } else { 97 } else {
94 Image(systemName: "globe") 98 Image(systemName: "globe")
95 .font(.system(size: 10)) 99 .font(.system(size: 10))
96− .foregroundStyle(ZyquoColor.textTertiary) 100+ .foregroundStyle(theme.textTertiary)
97 } 101 }
98 } 102 }
99 } 103 }
modified Sources/ZyquoAtlas/Views/Browser/ToolbarView.swift +10 −6
@@ -19,6 +19,9 @@ struct ToolbarView: View {
19 let onSubmit: (String) -> Void 19 let onSubmit: (String) -> Void
20 let onNewTab: () -> Void 20 let onNewTab: () -> Void
21 let onToggleAI: () -> Void 21 let onToggleAI: () -> Void
22+ let onCustomize: () -> Void
23+ @EnvironmentObject private var themeEngine: ThemeEngine
24+ private var theme: AtlasTheme { themeEngine.theme }
22 25
23 var body: some View { 26 var body: some View {
24 VStack(spacing: 0) { 27 VStack(spacing: 0) {
@@ -35,17 +38,18 @@ struct ToolbarView: View {
35 .frame(maxWidth: .infinity) 38 .frame(maxWidth: .infinity)
36 39
37 navButton("plus", enabled: true, action: onNewTab) 40 navButton("plus", enabled: true, action: onNewTab)
41+ navButton("paintbrush", enabled: true, action: onCustomize)
38 aiToggle 42 aiToggle
39 } 43 }
40 .padding(.horizontal, ZyquoSpacing.sm) 44 .padding(.horizontal, ZyquoSpacing.sm)
41− .frame(height: ZyquoMetrics.toolbarHeight) 45+ .frame(height: themeEngine.layout.toolbarHeight)
42 46
43 progressBar 47 progressBar
44 } 48 }
45− .background(ZyquoColor.surface) 49+ .background(theme.surface)
46 .overlay(alignment: .bottom) { 50 .overlay(alignment: .bottom) {
47 Rectangle() 51 Rectangle()
48− .fill(ZyquoColor.border) 52+ .fill(theme.border)
49 .frame(height: ZyquoMetrics.hairline) 53 .frame(height: ZyquoMetrics.hairline)
50 } 54 }
51 } 55 }
@@ -57,7 +61,7 @@ struct ToolbarView: View {
57 GeometryReader { geo in 61 GeometryReader { geo in
58 if tab.showsProgress { 62 if tab.showsProgress {
59 Rectangle() 63 Rectangle()
60− .fill(ZyquoColor.accent) 64+ .fill(theme.accent)
61 .frame(width: geo.size.width * max(0.02, tab.estimatedProgress)) 65 .frame(width: geo.size.width * max(0.02, tab.estimatedProgress))
62 .animation(.easeOut(duration: 0.2), value: tab.estimatedProgress) 66 .animation(.easeOut(duration: 0.2), value: tab.estimatedProgress)
63 } 67 }
@@ -73,7 +77,7 @@ struct ToolbarView: View {
73 .contentShape(Rectangle()) 77 .contentShape(Rectangle())
74 } 78 }
75 .buttonStyle(.plain) 79 .buttonStyle(.plain)
76− .foregroundStyle(isAISidebarOpen ? ZyquoColor.accentIndigo : ZyquoColor.textSecondary) 80+ .foregroundStyle(isAISidebarOpen ? theme.accentIndigo : theme.textSecondary)
77 .help("Toggle Atlas AI sidebar") 81 .help("Toggle Atlas AI sidebar")
78 } 82 }
79 83
@@ -87,7 +91,7 @@ struct ToolbarView: View {
87 .contentShape(Rectangle()) 91 .contentShape(Rectangle())
88 } 92 }
89 .buttonStyle(.plain) 93 .buttonStyle(.plain)
90− .foregroundStyle(enabled ? ZyquoColor.textSecondary : ZyquoColor.textTertiary.opacity(0.5)) 94+ .foregroundStyle(enabled ? theme.textSecondary : theme.textTertiary.opacity(0.5))
91 .disabled(!enabled) 95 .disabled(!enabled)
92 } 96 }
93 } 97 }
added Sources/ZyquoAtlas/Views/Browser/VerticalTabBarView.swift +103 −0
@@ -0,0 +1,103 @@
1+//
2+// VerticalTabBarView.swift
3+// Zyquo Atlas
4+//
5+// Author: Simon-Pierre Boucher
6+// Mail: contact@spboucher.ai
7+//
8+// The left-vertical tab sidebar (Arc-style), used when the layout's
9+// tabPosition is .left. Same TabManager, a stacked column of tabs with a
10+// new-tab button at the top. Themed live by ThemeEngine.
11+//
12+
13+import SwiftUI
14+
15+struct VerticalTabBarView: View {
16+ @ObservedObject var tabManager: TabManager
17+ let onNewTab: () -> Void
18+ @EnvironmentObject private var themeEngine: ThemeEngine
19+ private var theme: AtlasTheme { themeEngine.theme }
20+
21+ var body: some View {
22+ VStack(alignment: .leading, spacing: themeEngine.layout.rowSpacing) {
23+ HStack {
24+ Text("Zyquo Atlas")
25+ .font(themeEngine.layout.font(12, weight: .semibold))
26+ .foregroundStyle(theme.textSecondary)
27+ Spacer()
28+ Button(action: onNewTab) {
29+ Image(systemName: "plus")
30+ .font(.system(size: 11, weight: .bold))
31+ .frame(width: 22, height: 22)
32+ .contentShape(Rectangle())
33+ }
34+ .buttonStyle(.plain)
35+ .foregroundStyle(theme.textSecondary)
36+ }
37+ .padding(.bottom, ZyquoSpacing.xxs)
38+
39+ ScrollView(showsIndicators: false) {
40+ VStack(spacing: themeEngine.layout.rowSpacing) {
41+ ForEach(tabManager.tabs) { tab in
42+ VerticalTabRow(
43+ tab: tab,
44+ isActive: tab.id == tabManager.activeTabID,
45+ onSelect: { tabManager.selectTab(tab.id) },
46+ onClose: { tabManager.closeTab(tab.id) }
47+ )
48+ }
49+ }
50+ }
51+ Spacer()
52+ }
53+ .padding(ZyquoSpacing.xs)
54+ .frame(width: themeEngine.layout.verticalTabWidth)
55+ .background(theme.surfaceSecondary)
56+ .overlay(alignment: .trailing) {
57+ Rectangle().fill(theme.border).frame(width: ZyquoMetrics.hairline)
58+ }
59+ }
60+}
61+
62+private struct VerticalTabRow: View {
63+ @ObservedObject var tab: Tab
64+ let isActive: Bool
65+ let onSelect: () -> Void
66+ let onClose: () -> Void
67+ @EnvironmentObject private var themeEngine: ThemeEngine
68+ private var theme: AtlasTheme { themeEngine.theme }
69+ @State private var hovering = false
70+
71+ var body: some View {
72+ HStack(spacing: ZyquoSpacing.xs) {
73+ if tab.isLoading {
74+ ProgressView().controlSize(.mini).scaleEffect(0.6).frame(width: 14)
75+ } else {
76+ Image(systemName: "globe").font(.system(size: 10))
77+ .foregroundStyle(theme.textTertiary).frame(width: 14)
78+ }
79+ Text(tab.title)
80+ .font(themeEngine.layout.font(12.5))
81+ .lineLimit(1)
82+ .foregroundStyle(isActive ? theme.textPrimary : theme.textSecondary)
83+ Spacer(minLength: 0)
84+ if hovering || isActive {
85+ Button(action: onClose) {
86+ Image(systemName: "xmark").font(.system(size: 9, weight: .bold))
87+ .frame(width: 16, height: 16).contentShape(Rectangle())
88+ }
89+ .buttonStyle(.plain)
90+ .foregroundStyle(theme.textTertiary)
91+ }
92+ }
93+ .padding(.horizontal, ZyquoSpacing.xs)
94+ .frame(height: 30)
95+ .background(
96+ RoundedRectangle(cornerRadius: ZyquoRadius.small)
97+ .fill(isActive ? theme.accentSubtle : (hovering ? theme.surface : .clear))
98+ )
99+ .contentShape(Rectangle())
100+ .onTapGesture(perform: onSelect)
101+ .onHover { hovering = $0 }
102+ }
103+}
added Sources/ZyquoAtlas/Views/Settings/CustomizationView.swift +273 −0
@@ -0,0 +1,273 @@
1+//
2+// CustomizationView.swift
3+// Zyquo Atlas
4+//
5+// Author: Simon-Pierre Boucher
6+// Mail: contact@spboucher.ai
7+//
8+// The customization panel (Phase 4.2 headline UI): pick a built-in theme with
9+// live preview, create/edit a custom theme (accent + background + chrome),
10+// choose the tab layout and density, and set UI scale. Every change applies
11+// live through ThemeEngine. Import/export wire to small JSON theme files.
12+//
13+
14+import SwiftUI
15+import AppKit
16+
17+struct CustomizationView: View {
18+ @EnvironmentObject private var themeEngine: ThemeEngine
19+ @Environment(\.dismiss) private var dismiss
20+ private var theme: AtlasTheme { themeEngine.theme }
21+
22+ var body: some View {
23+ VStack(spacing: 0) {
24+ header
25+ Divider().overlay(theme.border)
26+ ScrollView {
27+ VStack(alignment: .leading, spacing: ZyquoSpacing.xl) {
28+ themesSection
29+ customEditorSection
30+ layoutSection
31+ typographySection
32+ }
33+ .padding(ZyquoSpacing.lg)
34+ }
35+ }
36+ .frame(width: 640, height: 620)
37+ .background(theme.backgroundColor)
38+ }
39+
40+ // MARK: - Header
41+
42+ private var header: some View {
43+ HStack {
44+ Label("Customize", systemImage: "paintbrush")
45+ .font(ZyquoFont.title)
46+ .foregroundStyle(theme.textPrimary)
47+ Spacer()
48+ Button("Done") { dismiss() }
49+ .foregroundStyle(theme.accent)
50+ }
51+ .padding(ZyquoSpacing.md)
52+ .background(theme.surface)
53+ }
54+
55+ // MARK: - Theme gallery
56+
57+ private var themesSection: some View {
58+ section("Theme") {
59+ LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: ZyquoSpacing.sm), count: 3),
60+ spacing: ZyquoSpacing.sm) {
61+ ForEach(themeEngine.gallery) { t in
62+ ThemeSwatch(theme: t, isSelected: t.id == themeEngine.selectedThemeID)
63+ .onTapGesture { themeEngine.select(t.id) }
64+ }
65+ }
66+ HStack(spacing: ZyquoSpacing.sm) {
67+ Toggle("Match system light/dark", isOn: Binding(
68+ get: { themeEngine.matchSystemAppearance },
69+ set: { themeEngine.setMatchSystem($0, systemIsDark: systemIsDark) }
70+ ))
71+ .toggleStyle(.switch)
72+ .font(ZyquoFont.body())
73+ .foregroundStyle(theme.textPrimary)
74+ Spacer()
75+ Button("Import…") { importTheme() }.foregroundStyle(theme.accent)
76+ Button("Export…") { exportTheme() }.foregroundStyle(theme.accent)
77+ }
78+ }
79+ }
80+
81+ // MARK: - Custom editor
82+
83+ private var customEditorSection: some View {
84+ section("Custom theme") {
85+ HStack(spacing: ZyquoSpacing.md) {
86+ colorWell("Accent", get: { theme.palette.accent }, set: { v in editPalette { $0.accent = v } })
87+ colorWell("Background", get: { theme.palette.background }, set: { v in editPalette { $0.background = v } })
88+ colorWell("Chrome", get: { theme.palette.surface }, set: { v in editPalette { $0.surface = v } })
89+ Spacer()
90+ }
91+ Text(isCustom
92+ ? "Editing “\(theme.name)”. Colors apply live."
93+ : "Editing a built-in theme creates an editable copy.")
94+ .font(ZyquoFont.caption)
95+ .foregroundStyle(theme.textSecondary)
96+ }
97+ }
98+
99+ // MARK: - Layout
100+
101+ private var layoutSection: some View {
102+ section("Layout") {
103+ HStack(spacing: ZyquoSpacing.lg) {
104+ labeledPicker("Tabs") {
105+ Picker("", selection: Binding(
106+ get: { themeEngine.layout.tabPosition },
107+ set: { pos in themeEngine.updateLayout { $0.tabPosition = pos } }
108+ )) {
109+ Text("Top").tag(TabPosition.top)
110+ Text("Left").tag(TabPosition.left)
111+ }.pickerStyle(.segmented).labelsHidden().frame(width: 160)
112+ }
113+ labeledPicker("Density") {
114+ Picker("", selection: Binding(
115+ get: { themeEngine.layout.density },
116+ set: { d in themeEngine.updateLayout { $0.density = d } }
117+ )) {
118+ Text("Comfortable").tag(Density.comfortable)
119+ Text("Compact").tag(Density.compact)
120+ }.pickerStyle(.segmented).labelsHidden().frame(width: 200)
121+ }
122+ }
123+ Toggle("Bookmarks bar", isOn: Binding(
124+ get: { themeEngine.layout.showBookmarksBar },
125+ set: { on in themeEngine.updateLayout { $0.showBookmarksBar = on } }
126+ ))
127+ .toggleStyle(.switch).font(ZyquoFont.body()).foregroundStyle(theme.textPrimary)
128+ }
129+ }
130+
131+ // MARK: - Typography
132+
133+ private var typographySection: some View {
134+ section("Display") {
135+ HStack {
136+ Text("UI size").font(ZyquoFont.body()).foregroundStyle(theme.textPrimary)
137+ Slider(value: Binding(
138+ get: { themeEngine.layout.uiScale },
139+ set: { s in themeEngine.updateLayout { $0.uiScale = s } }
140+ ), in: 0.85...1.30, step: 0.05)
141+ Text(String(format: "%.0f%%", themeEngine.layout.uiScale * 100))
142+ .font(ZyquoFont.caption).foregroundStyle(theme.textSecondary).frame(width: 44)
143+ }
144+ }
145+ }
146+
147+ // MARK: - Helpers
148+
149+ private var isCustom: Bool { theme.id.hasPrefix("custom-") }
150+ private var systemIsDark: Bool {
151+ NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
152+ }
153+
154+ private func editPalette(_ mutate: (inout AtlasPalette) -> Void) {
155+ var t = ensureCustom()
156+ mutate(&t.palette)
157+ themeEngine.updateCustom(t)
158+ }
159+
160+ /// Ensures edits target a custom theme (duplicates a built-in on first edit).
161+ private func ensureCustom() -> AtlasTheme {
162+ if isCustom { return theme }
163+ return themeEngine.duplicateForEditing(theme, name: theme.name + " (Custom)")
164+ }
165+
166+ private func colorWell(_ label: String, get: @escaping () -> UInt32,
167+ set: @escaping (UInt32) -> Void) -> some View {
168+ VStack(spacing: ZyquoSpacing.xxs) {
169+ ColorWellBridge(hex: get(), onChange: set).frame(width: 44, height: 28)
170+ Text(label).font(ZyquoFont.caption).foregroundStyle(theme.textSecondary)
171+ }
172+ }
173+
174+ private func labeledPicker<Content: View>(_ label: String, @ViewBuilder _ content: () -> Content) -> some View {
175+ VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {
176+ Text(label).font(ZyquoFont.caption).foregroundStyle(theme.textSecondary)
177+ content()
178+ }
179+ }
180+
181+ private func section<Content: View>(_ title: String, @ViewBuilder _ content: () -> Content) -> some View {
182+ VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {
183+ Text(title.uppercased())
184+ .font(ZyquoFont.caption)
185+ .foregroundStyle(theme.textTertiary)
186+ content()
187+ }
188+ }
189+
190+ // MARK: - Import / export
191+
192+ private func importTheme() {
193+ let panel = NSOpenPanel()
194+ panel.allowedContentTypes = [.json]
195+ panel.allowsMultipleSelection = false
196+ if panel.runModal() == .OK, let url = panel.url {
197+ _ = try? themeEngine.importTheme(from: url)
198+ }
199+ }
200+
201+ private func exportTheme() {
202+ let panel = NSSavePanel()
203+ panel.allowedContentTypes = [.json]
204+ panel.nameFieldStringValue = theme.name.replacingOccurrences(of: " ", with: "-") + ".json"
205+ if panel.runModal() == .OK, let url = panel.url {
206+ try? themeEngine.exportTheme(theme, to: url) // best-effort
207+ }
208+ }
209+}
210+
211+// MARK: - Theme swatch
212+
213+private struct ThemeSwatch: View {
214+ let theme: AtlasTheme
215+ let isSelected: Bool
216+
217+ var body: some View {
218+ VStack(spacing: ZyquoSpacing.xxs) {
219+ ZStack(alignment: .bottomLeading) {
220+ RoundedRectangle(cornerRadius: ZyquoRadius.medium)
221+ .fill(theme.backgroundColor)
222+ HStack(spacing: 3) {
223+ Circle().fill(theme.accent).frame(width: 10, height: 10)
224+ Circle().fill(theme.accentIndigo).frame(width: 10, height: 10)
225+ RoundedRectangle(cornerRadius: 2).fill(theme.surface).frame(width: 22, height: 8)
226+ }
227+ .padding(ZyquoSpacing.xs)
228+ }
229+ .frame(height: 54)
230+ .overlay(RoundedRectangle(cornerRadius: ZyquoRadius.medium)
231+ .strokeBorder(isSelected ? theme.accent : theme.border,
232+ lineWidth: isSelected ? 2 : ZyquoMetrics.hairline))
233+ Text(theme.name)
234+ .font(ZyquoFont.caption)
235+ .foregroundStyle(theme.textSecondary)
236+ .lineLimit(1)
237+ }
238+ }
239+}
240+
241+/// Bridges an NSColorWell so the custom-theme editor gets a native color picker.
242+private struct ColorWellBridge: NSViewRepresentable {
243+ let hex: UInt32
244+ let onChange: (UInt32) -> Void
245+
246+ func makeNSView(context: Context) -> NSColorWell {
247+ let well = NSColorWell()
248+ well.color = NSColor(hex: hex)
249+ well.target = context.coordinator
250+ well.action = #selector(Coordinator.changed(_:))
251+ return well
252+ }
253+
254+ func updateNSView(_ nsView: NSColorWell, context: Context) {
255+ context.coordinator.onChange = onChange
256+ nsView.color = NSColor(hex: hex)
257+ }
258+
259+ func makeCoordinator() -> Coordinator { Coordinator(onChange: onChange) }
260+
261+ final class Coordinator: NSObject {
262+ var onChange: (UInt32) -> Void
263+ init(onChange: @escaping (UInt32) -> Void) { self.onChange = onChange }
264+
265+ @objc func changed(_ sender: NSColorWell) {
266+ guard let rgb = sender.color.usingColorSpace(.sRGB) else { return }
267+ let r = UInt32(round(rgb.redComponent * 255))
268+ let g = UInt32(round(rgb.greenComponent * 255))
269+ let b = UInt32(round(rgb.blueComponent * 255))
270+ onChange((r << 16) | (g << 8) | b)
271+ }
272+ }
273+}
modified docs/PLAN.md +13 −0
@@ -106,3 +106,16 @@ launchable, single window; browser core is Phase 2).
106 - [x] `--load-vault` seeds encrypted vault from env (all 12 providers seeded); `--selftest-summarize` headless harness 106 - [x] `--load-vault` seeds encrypted vault from env (all 12 providers seeded); `--selftest-summarize` headless harness
107 107
108 **PHASE GATE verified (2026-07-30):** `ZyquoAtlas --selftest-summarize https://en.wikipedia.org/wiki/Cartography --provider openai` loaded the live article, extracted it (quality=reader, 9210 words, ~17K tokens, 27 headings, not truncated), chose the `stuff` plan, and **streamed an accurate, grounded summary + key takeaways** from a real Cloud model (gpt-5.2-chat-latest) — the exact ContentExtractor + Summarizer + AIService + ported provider client the GUI calls. GUI builds/launches with the AI sidebar; vault seeded. **Phase 3 gate PASSED.** Notes: running the unsigned bundle from ~/Desktop triggers a macOS Desktop-access TCC prompt (gone once notarized/installed in Phase 8); Readability output includes some Wikipedia nav chrome (extraction-quality tuning tracked for Phase 7's extraction suite). 108 **PHASE GATE verified (2026-07-30):** `ZyquoAtlas --selftest-summarize https://en.wikipedia.org/wiki/Cartography --provider openai` loaded the live article, extracted it (quality=reader, 9210 words, ~17K tokens, 27 headings, not truncated), chose the `stuff` plan, and **streamed an accurate, grounded summary + key takeaways** from a real Cloud model (gpt-5.2-chat-latest) — the exact ContentExtractor + Summarizer + AIService + ported provider client the GUI calls. GUI builds/launches with the AI sidebar; vault seeded. **Phase 3 gate PASSED.** Notes: running the unsigned bundle from ~/Desktop triggers a macOS Desktop-access TCC prompt (gone once notarized/installed in Phase 8); Readability output includes some Wikipedia nav chrome (extraction-quality tuning tracked for Phase 7's extraction suite).
109+
110+## Phase 4 — Design System, Theming Engine & UI — GATE PASSED
111+
112+- [x] `AtlasTheme` (Codable palette + background:solid/gradient/image + appearance) with resolved SwiftUI colors; `LayoutSettings` (tabPosition, density, uiScale, pageZoom, bookmarks bar, translucency) with density-derived metrics
113+- [x] 10 built-in themes (flagship **Atlas Light** = Phase 4.1 base spec, Atlas Dark, Cartographer, Meridian, Terra, Slate, Rose Quartz, Midnight, Nocturne, Aurora) — light & dark, some gradient backgrounds
114+- [x] `ThemeEngine` (ObservableObject): live-applied active theme, custom-theme create/edit, per-profile JSON persistence, import/export, match-system light/dark
115+- [x] Refactored all chrome views to read the injected live theme (removed the now-dead static ZyquoColor); ZyquoFont/Spacing/Radius/Metrics retained
116+- [x] Layout: **top-horizontal OR left-vertical (Arc-style) tab bar**, compact/comfortable density, bookmarks-bar toggle — all live
117+- [x] `StartPageView` — customizable new-tab page (theme background, greeting, prominent search/Ask-AI box, favorites grid)
118+- [x] `CustomizationView` — theme gallery w/ live swatches, custom editor (NSColorWell accent/background/chrome), tab layout + density pickers, UI-size slider, import/export
119+- [x] Base light theme matches 4.1 spec; dark derived; motion (150ms fades, layout transitions)
120+
121+**PHASE GATE verified (2026-07-30):** launched from /tmp (avoids the ~/Desktop TCC prompt). Screenshots confirm: (1) **flagship Atlas Light** — crisp off-white chrome, teal omnibox focus ring, start page with Ask-AI chip + favorites grid; (2) **Midnight dark theme + left vertical tabs** loaded from the persisted JSON on relaunch — proving live theme-switch, the top/left layout system, dark-theme derivation, and per-profile persistence. Build clean (zero warnings), 19 tests green, headers present, no dead code. **Phase 4 gate PASSED** — this is the UI contract for Phase 6. Deferred to Phase 6 (built on their services): full favorites manager, history view, downloads, reader mode.
109 122
Menu

Theme