SPB Git

spb/zyquo-atlas Public License

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

Swift 75.2% JavaScript 22% Shell 2% Makefile 0.9%

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 11 days ago (Jul 30, 2026) parent 2bc98e6

Showing 14 changed files with +1,051 and −91

modified Sources/ZyquoAtlas/App/ZyquoAtlasApp.swift +3 −0
@@ -16,11 +16,14 @@ import AppKit
16 16 struct ZyquoAtlasApp: App {
17 17 @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
18 18 @StateObject private var appEnvironment = AppEnvironment()
19 + @StateObject private var themeEngine = ThemeEngine()
19 20
20 21 var body: some Scene {
21 22 WindowGroup("Zyquo Atlas") {
22 23 BrowserWindowView()
23 24 .environmentObject(appEnvironment)
25 + .environmentObject(themeEngine)
26 + .preferredColorScheme(themeEngine.theme.colorScheme)
24 27 }
25 28 .windowStyle(.hiddenTitleBar)
26 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 5 // Author: Simon-Pierre Boucher
6 6 // Mail: contact@spboucher.ai
7 7 //
8 // The design system. Every color, font, spacing, radius, and shadow comes from
9 // these base tokens — views contain zero raw hex values or magic numbers. The
10 // light theme is the flagship (Phase 4.1 spec); dark derives from the same
11 // semantic tokens. Atlas's identity is a balanced teal-indigo "map/atlas"
12 // story. In Phase 4 the ThemeEngine overrides these base values with user
13 // themes; until then these are resolved directly.
8 +// The non-color design tokens: typography, spacing, radii, shadows, and layout
9 +// metrics — views contain zero magic numbers. Colors live in AtlasTheme /
10 +// BuiltInThemes and are applied live by ThemeEngine (the flagship "Atlas Light"
11 +// theme is the Phase 4.1 base spec); fonts scale with the user's uiScale via
12 +// LayoutSettings. Atlas's identity is a balanced teal-indigo "map/atlas" story.
14 13 //
15 14
16 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 17 extension NSColor {
54 18 /// 0xRRGGBB → NSColor (sRGB).
55 19 convenience init(hex: UInt32) {
modified Sources/ZyquoAtlas/Views/AI/AISidebarView.swift +17 −15
@@ -18,21 +18,23 @@ struct AISidebarView: View {
18 18 @ObservedObject var tab: Tab
19 19 @ObservedObject var ai: AIService
20 20 let env: AppEnvironment
21 + @EnvironmentObject private var themeEngine: ThemeEngine
22 + private var theme: AtlasTheme { themeEngine.theme }
21 23
22 24 @State private var extractionError: String?
23 25
24 26 var body: some View {
25 27 VStack(alignment: .leading, spacing: 0) {
26 28 header
27 Divider().overlay(ZyquoColor.border)
29 + Divider().overlay(theme.border)
28 30 actionBar
29 Divider().overlay(ZyquoColor.border)
31 + Divider().overlay(theme.border)
30 32 answer
31 33 }
32 34 .frame(width: ZyquoMetrics.aiSidebarWidth)
33 .background(ZyquoColor.surface)
35 + .background(theme.surface)
34 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 43 private var header: some View {
42 44 HStack(spacing: ZyquoSpacing.xs) {
43 45 Image(systemName: "sparkles")
44 .foregroundStyle(ZyquoColor.accentIndigo)
46 + .foregroundStyle(theme.accentIndigo)
45 47 Text("Atlas AI")
46 48 .font(ZyquoFont.bodyEmphasis())
47 .foregroundStyle(ZyquoColor.textPrimary)
49 + .foregroundStyle(theme.textPrimary)
48 50 Spacer()
49 51 Text(env.defaultModel?.displayName ?? "No model")
50 52 .font(ZyquoFont.caption)
51 .foregroundStyle(ZyquoColor.textSecondary)
53 + .foregroundStyle(theme.textSecondary)
52 54 .padding(.horizontal, ZyquoSpacing.xs)
53 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 58 .padding(ZyquoSpacing.sm)
57 59 }
@@ -62,7 +64,7 @@ struct AISidebarView: View {
62 64 if ai.isStreaming {
63 65 Button("Stop") { ai.cancel() }
64 66 .font(ZyquoFont.caption)
65 .foregroundStyle(ZyquoColor.danger)
67 + .foregroundStyle(theme.danger)
66 68 }
67 69 Spacer()
68 70 }
@@ -76,22 +78,22 @@ struct AISidebarView: View {
76 78 if let note = ai.statusNote {
77 79 Label(note, systemImage: "arrow.triangle.2.circlepath")
78 80 .font(ZyquoFont.caption)
79 .foregroundStyle(ZyquoColor.textSecondary)
81 + .foregroundStyle(theme.textSecondary)
80 82 }
81 83 if let err = extractionError ?? ai.errorText {
82 84 Label(err, systemImage: "exclamationmark.triangle")
83 85 .font(ZyquoFont.caption)
84 .foregroundStyle(ZyquoColor.danger)
86 + .foregroundStyle(theme.danger)
85 87 }
86 88 if !ai.output.isEmpty {
87 89 Text(ai.output)
88 90 .font(ZyquoFont.body())
89 .foregroundStyle(ZyquoColor.textPrimary)
91 + .foregroundStyle(theme.textPrimary)
90 92 .textSelection(.enabled)
91 93 } else if !ai.isStreaming && extractionError == nil {
92 94 Text("Ask about this page or summarize it. Page content is sent to your chosen provider only when you run an action.")
93 95 .font(ZyquoFont.caption)
94 .foregroundStyle(ZyquoColor.textTertiary)
96 + .foregroundStyle(theme.textTertiary)
95 97 }
96 98 }
97 99 .frame(maxWidth: .infinity, alignment: .leading)
@@ -109,8 +111,8 @@ struct AISidebarView: View {
109 111 .padding(.vertical, ZyquoSpacing.xxs)
110 112 }
111 113 .buttonStyle(.plain)
112 .foregroundStyle(ZyquoColor.accent)
113 .background(RoundedRectangle(cornerRadius: ZyquoRadius.small).fill(ZyquoColor.accentSubtle))
114 + .foregroundStyle(theme.accent)
115 + .background(RoundedRectangle(cornerRadius: ZyquoRadius.small).fill(theme.accentSubtle))
114 116 .disabled(ai.isStreaming)
115 117 }
116 118
modified Sources/ZyquoAtlas/Views/Browser/BrowserWindowView.swift +54 −16
@@ -5,10 +5,10 @@
5 5 // Author: Simon-Pierre Boucher
6 6 // Mail: contact@spboucher.ai
7 7 //
8 // The browser window root: tab strip on top, toolbar (omnibox + nav +
9 // progress), then the active tab's web content. Owns the window's TabManager.
10 // Replaces the Phase 1 placeholder. The AI sidebar, bookmarks bar, and
11 // vertical-tab layout attach around this in later phases.
8 +// The browser window root. Composes the chrome — tab strip (top-horizontal OR
9 +// left-vertical per the live layout setting), toolbar (omnibox + nav +
10 +// progress), web content, and the AI sidebar — all themed live by ThemeEngine.
11 +// Owns the window's TabManager. The customization panel opens as a sheet.
12 12 //
13 13
14 14 import SwiftUI
@@ -16,36 +16,74 @@ import SwiftUI
16 16 struct BrowserWindowView: View {
17 17 @StateObject private var tabManager = TabManager(profile: .defaultProfile)
18 18 @EnvironmentObject private var env: AppEnvironment
19 + @EnvironmentObject private var themeEngine: ThemeEngine
19 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 26 var body: some View {
22 VStack(spacing: 0) {
23 TabBarView(tabManager: tabManager)
27 + Group {
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 56 ToolbarView(
27 57 tab: tab,
28 58 isAISidebarOpen: showAISidebar,
29 59 onSubmit: { tabManager.loadInActiveTab($0) },
30 60 onNewTab: { tabManager.newTab() },
31 onToggleAI: { showAISidebar.toggle() }
61 + onToggleAI: { showAISidebar.toggle() },
62 + onCustomize: { showCustomize = true }
32 63 )
33 64 HStack(spacing: 0) {
34 WebContentArea(tab: tab)
65 + contentArea(for: tab)
35 66 if showAISidebar {
36 67 AISidebarView(tab: tab, ai: tab.ai, env: env)
37 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)
45 .frame(minWidth: 900, minHeight: 600)
46 .animation(.easeInOut(duration: 0.15), value: showAISidebar)
47 .onAppear {
48 if tabManager.tabs.isEmpty { tabManager.newTab() }
75 + }
76 +
77 + /// Shows the customizable start page on a blank tab, else the web content.
78 + @ViewBuilder
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 15 struct OmniboxView: View {
16 16 @ObservedObject var tab: Tab
17 17 let onSubmit: (String) -> Void
18 + @EnvironmentObject private var themeEngine: ThemeEngine
19 + private var theme: AtlasTheme { themeEngine.theme }
18 20
19 21 @State private var text: String = ""
20 22 @State private var isEditing: Bool = false
@@ -29,7 +31,7 @@ struct OmniboxView: View {
29 31 TextField("Search or enter website name", text: $text)
30 32 .textFieldStyle(.plain)
31 33 .font(ZyquoFont.control)
32 .foregroundStyle(ZyquoColor.textPrimary)
34 + .foregroundStyle(theme.textPrimary)
33 35 .focused($focused)
34 36 .onSubmit {
35 37 onSubmit(text)
@@ -46,7 +48,7 @@ struct OmniboxView: View {
46 48 } label: {
47 49 Image(systemName: "xmark.circle.fill")
48 50 .font(.system(size: 12))
49 .foregroundStyle(ZyquoColor.textTertiary)
51 + .foregroundStyle(theme.textTertiary)
50 52 }
51 53 .buttonStyle(.plain)
52 54 }
@@ -55,11 +57,11 @@ struct OmniboxView: View {
55 57 .frame(height: 30)
56 58 .background(
57 59 RoundedRectangle(cornerRadius: ZyquoRadius.small)
58 .fill(ZyquoColor.surfaceSecondary)
60 + .fill(theme.surfaceSecondary)
59 61 )
60 62 .overlay(
61 63 RoundedRectangle(cornerRadius: ZyquoRadius.small)
62 .strokeBorder(focused ? ZyquoColor.accent : ZyquoColor.border,
64 + .strokeBorder(focused ? theme.accent : theme.border,
63 65 lineWidth: focused ? 1.5 : ZyquoMetrics.hairline)
64 66 )
65 67 .onChange(of: tab.url) { _ in if !isEditing { syncFromTab() } }
@@ -74,8 +76,8 @@ struct OmniboxView: View {
74 76 }
75 77
76 78 private var securityColor: Color {
77 guard tab.url != nil else { return ZyquoColor.textTertiary }
78 return tab.hasSecureConnection ? ZyquoColor.textSecondary : ZyquoColor.warning
79 + guard tab.url != nil else { return theme.textTertiary }
80 + return tab.hasSecureConnection ? theme.textSecondary : theme.warning
79 81 }
80 82
81 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 16 struct TabBarView: View {
17 17 @ObservedObject var tabManager: TabManager
18 + @EnvironmentObject private var themeEngine: ThemeEngine
19 + private var theme: AtlasTheme { themeEngine.theme }
18 20
19 21 var body: some View {
20 22 ScrollView(.horizontal, showsIndicators: false) {
@@ -31,7 +33,7 @@ struct TabBarView: View {
31 33 .padding(.horizontal, ZyquoSpacing.xs)
32 34 }
33 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 42 let isActive: Bool
41 43 let onSelect: () -> Void
42 44 let onClose: () -> Void
45 + @EnvironmentObject private var themeEngine: ThemeEngine
46 + private var theme: AtlasTheme { themeEngine.theme }
43 47
44 48 @State private var hovering = false
45 49
@@ -51,7 +55,7 @@ private struct TabChip: View {
51 55 Text(tab.title)
52 56 .font(ZyquoFont.control)
53 57 .lineLimit(1)
54 .foregroundStyle(isActive ? ZyquoColor.textPrimary : ZyquoColor.textSecondary)
58 + .foregroundStyle(isActive ? theme.textPrimary : theme.textSecondary)
55 59
56 60 Spacer(minLength: 0)
57 61
@@ -63,19 +67,19 @@ private struct TabChip: View {
63 67 .contentShape(Rectangle())
64 68 }
65 69 .buttonStyle(.plain)
66 .foregroundStyle(ZyquoColor.textTertiary)
70 + .foregroundStyle(theme.textTertiary)
67 71 }
68 72 }
69 73 .padding(.horizontal, ZyquoSpacing.sm)
70 74 .frame(width: ZyquoMetrics.tabMinWidth, height: ZyquoMetrics.tabBarHeight - 6)
71 75 .background(
72 76 RoundedRectangle(cornerRadius: ZyquoRadius.small)
73 .fill(isActive ? ZyquoColor.accentSubtle
74 : (hovering ? ZyquoColor.surfaceSecondary : .clear))
77 + .fill(isActive ? theme.accentSubtle
78 + : (hovering ? theme.surfaceSecondary : .clear))
75 79 )
76 80 .overlay(alignment: .bottom) {
77 81 if isActive {
78 Rectangle().fill(ZyquoColor.accent).frame(height: 2)
82 + Rectangle().fill(theme.accent).frame(height: 2)
79 83 .padding(.horizontal, ZyquoSpacing.xs)
80 84 }
81 85 }
@@ -93,7 +97,7 @@ private struct TabChip: View {
93 97 } else {
94 98 Image(systemName: "globe")
95 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 19 let onSubmit: (String) -> Void
20 20 let onNewTab: () -> Void
21 21 let onToggleAI: () -> Void
22 + let onCustomize: () -> Void
23 + @EnvironmentObject private var themeEngine: ThemeEngine
24 + private var theme: AtlasTheme { themeEngine.theme }
22 25
23 26 var body: some View {
24 27 VStack(spacing: 0) {
@@ -35,17 +38,18 @@ struct ToolbarView: View {
35 38 .frame(maxWidth: .infinity)
36 39
37 40 navButton("plus", enabled: true, action: onNewTab)
41 + navButton("paintbrush", enabled: true, action: onCustomize)
38 42 aiToggle
39 43 }
40 44 .padding(.horizontal, ZyquoSpacing.sm)
41 .frame(height: ZyquoMetrics.toolbarHeight)
45 + .frame(height: themeEngine.layout.toolbarHeight)
42 46
43 47 progressBar
44 48 }
45 .background(ZyquoColor.surface)
49 + .background(theme.surface)
46 50 .overlay(alignment: .bottom) {
47 51 Rectangle()
48 .fill(ZyquoColor.border)
52 + .fill(theme.border)
49 53 .frame(height: ZyquoMetrics.hairline)
50 54 }
51 55 }
@@ -57,7 +61,7 @@ struct ToolbarView: View {
57 61 GeometryReader { geo in
58 62 if tab.showsProgress {
59 63 Rectangle()
60 .fill(ZyquoColor.accent)
64 + .fill(theme.accent)
61 65 .frame(width: geo.size.width * max(0.02, tab.estimatedProgress))
62 66 .animation(.easeOut(duration: 0.2), value: tab.estimatedProgress)
63 67 }
@@ -73,7 +77,7 @@ struct ToolbarView: View {
73 77 .contentShape(Rectangle())
74 78 }
75 79 .buttonStyle(.plain)
76 .foregroundStyle(isAISidebarOpen ? ZyquoColor.accentIndigo : ZyquoColor.textSecondary)
80 + .foregroundStyle(isAISidebarOpen ? theme.accentIndigo : theme.textSecondary)
77 81 .help("Toggle Atlas AI sidebar")
78 82 }
79 83
@@ -87,7 +91,7 @@ struct ToolbarView: View {
87 91 .contentShape(Rectangle())
88 92 }
89 93 .buttonStyle(.plain)
90 .foregroundStyle(enabled ? ZyquoColor.textSecondary : ZyquoColor.textTertiary.opacity(0.5))
94 + .foregroundStyle(enabled ? theme.textSecondary : theme.textTertiary.opacity(0.5))
91 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 106 - [x] `--load-vault` seeds encrypted vault from env (all 12 providers seeded); `--selftest-summarize` headless harness
107 107
108 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