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%

phase2: browser core — WKWebView tabs, TabManager, omnibox (URL vs search), toolbar+progress, tab bar, design tokens; renders live sites; Phase 2 gate PASSED

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 11 days ago (Jul 30, 2026) parent 4dff8ef

Showing 15 changed files with +1,034 and −36

modified Sources/ZyquoAtlas/App/ZyquoAtlasApp.swift +1 −2
@@ -18,8 +18,7 @@ struct ZyquoAtlasApp: App {
18 18
19 19 var body: some Scene {
20 20 WindowGroup("Zyquo Atlas") {
21 RootPlaceholderView()
22 .frame(minWidth: 900, minHeight: 600)
21 + BrowserWindowView()
23 22 }
24 23 .windowStyle(.hiddenTitleBar)
25 24 .windowToolbarStyle(.unified)
added Sources/ZyquoAtlas/Browser/OmniIntent.swift +83 −0
@@ -0,0 +1,83 @@
1 +//
2 +// OmniIntent.swift
3 +// Zyquo Atlas
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Resolves omnibox input into a navigable URL: direct navigation vs. web
9 +// search. This is the Phase 2 URL-vs-search half of the smart omnibox; the
10 +// Phase 3 "ask AI" intent (docs/AI-BROWSER-RESEARCH.md §4.1) layers on top as a
11 +// third route with explicit affordances — deliberately not auto-hijacking
12 +// navigate/search here.
13 +//
14 +
15 +import Foundation
16 +
17 +enum OmniIntent: Equatable {
18 + /// Navigate directly to this URL.
19 + case navigate(URL)
20 + /// Run a web search for this query.
21 + case search(String)
22 +
23 + /// The default search engine query template (`%@` replaced by the escaped query).
24 + static let searchTemplate = "https://duckduckgo.com/?q=%@"
25 +
26 + /// Classify raw omnibox text. Bias toward navigate/search (reversible,
27 + /// cheap) per the research; the AI "ask" route is opt-in elsewhere.
28 + static func resolve(_ raw: String) -> OmniIntent {
29 + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
30 + guard !text.isEmpty else { return .search("") }
31 +
32 + // Explicit scheme (http/https/file/about/data) → navigate.
33 + if let url = URL(string: text), let scheme = url.scheme?.lowercased(),
34 + ["http", "https", "file", "about", "data"].contains(scheme) {
35 + return .navigate(url)
36 + }
37 +
38 + // localhost / IP[:port] / bare host with a known-looking TLD and no
39 + // spaces → treat as a URL, defaulting to https.
40 + if !text.contains(" "), looksLikeHost(text) {
41 + if let url = URL(string: "https://\(text)") {
42 + return .navigate(url)
43 + }
44 + }
45 +
46 + return .search(text)
47 + }
48 +
49 + /// The URL to actually load for this intent.
50 + var url: URL? {
51 + switch self {
52 + case .navigate(let url):
53 + return url
54 + case .search(let query):
55 + let escaped = query.addingPercentEncoding(
56 + withAllowedCharacters: .urlQueryAllowed
57 + ) ?? query
58 + return URL(string: String(format: OmniIntent.searchTemplate, escaped))
59 + }
60 + }
61 +
62 + // MARK: - Heuristics
63 +
64 + private static func looksLikeHost(_ text: String) -> Bool {
65 + let host = text.split(separator: "/", maxSplits: 1).first.map(String.init) ?? text
66 + let bare = host.split(separator: ":", maxSplits: 1).first.map(String.init) ?? host
67 + if bare == "localhost" { return true }
68 + if isIPAddress(bare) { return true }
69 + // host.tld shape: at least one dot, a plausible TLD, no whitespace.
70 + guard let lastDot = bare.lastIndex(of: "."), lastDot != bare.startIndex else { return false }
71 + let tld = bare[bare.index(after: lastDot)...]
72 + return tld.count >= 2 && tld.allSatisfy { $0.isLetter }
73 + }
74 +
75 + private static func isIPAddress(_ s: String) -> Bool {
76 + let parts = s.split(separator: ".")
77 + guard parts.count == 4 else { return false }
78 + return parts.allSatisfy { part in
79 + guard let n = Int(part) else { return false }
80 + return (0...255).contains(n)
81 + }
82 + }
83 +}
added Sources/ZyquoAtlas/Browser/ProfileStore.swift +53 −0
@@ -0,0 +1,53 @@
1 +//
2 +// ProfileStore.swift
3 +// Zyquo Atlas
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Owns WKWebView configuration per profile: the profile's WKWebsiteDataStore
9 +// (persistent for normal profiles, non-persistent for private ones). All tabs
10 +// of a profile share cookies/storage through the same data store. Modern
11 +// WebKit pools and reuses content processes automatically (WKProcessPool is a
12 +// deprecated no-op), so no explicit pool is kept.
13 +//
14 +
15 +import Foundation
16 +import WebKit
17 +
18 +@MainActor
19 +final class ProfileStore {
20 + static let shared = ProfileStore()
21 +
22 + private var dataStores: [UUID: WKWebsiteDataStore] = [:]
23 +
24 + private init() {}
25 +
26 + /// The data store for a profile, created on first use. Persistent profiles
27 + /// get a stable on-disk store keyed by profile id (macOS 14+ identifier
28 + /// API); older systems and private profiles use the default/non-persistent
29 + /// stores.
30 + func dataStore(for profile: Profile) -> WKWebsiteDataStore {
31 + if let existing = dataStores[profile.id] { return existing }
32 + let store: WKWebsiteDataStore
33 + if profile.isPrivate {
34 + store = .nonPersistent()
35 + } else if #available(macOS 14.0, *), profile.id != Profile.defaultProfile.id {
36 + store = WKWebsiteDataStore(forIdentifier: profile.id)
37 + } else {
38 + store = .default()
39 + }
40 + dataStores[profile.id] = store
41 + return store
42 + }
43 +
44 + /// A fresh WKWebViewConfiguration for a tab in the given profile, wired to
45 + /// the shared process pool and the profile's data store.
46 + func makeConfiguration(for profile: Profile) -> WKWebViewConfiguration {
47 + let config = WKWebViewConfiguration()
48 + config.websiteDataStore = dataStore(for: profile)
49 + config.defaultWebpagePreferences.allowsContentJavaScript = true
50 + config.preferences.isElementFullscreenEnabled = true
51 + return config
52 + }
53 +}
added Sources/ZyquoAtlas/Browser/TabManager.swift +122 −0
@@ -0,0 +1,122 @@
1 +//
2 +// TabManager.swift
3 +// Zyquo Atlas
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Owns the ordered set of tabs and the active selection for a window. Handles
9 +// open / close / select / reorder, routes page-initiated new-tab requests from
10 +// each Tab, and suspends background tabs to save memory. Session restore, tab
11 +// groups/spaces, and pinning build on this in Phase 6.
12 +//
13 +
14 +import Foundation
15 +import WebKit
16 +import Combine
17 +
18 +@MainActor
19 +final class TabManager: ObservableObject {
20 + @Published private(set) var tabs: [Tab] = []
21 + @Published var activeTabID: Tab.ID?
22 +
23 + /// The home/new-tab destination until the customizable start page (Phase 4).
24 + static let homeURL = URL(string: "https://duckduckgo.com")!
25 +
26 + let profile: Profile
27 +
28 + init(profile: Profile = .defaultProfile) {
29 + self.profile = profile
30 + }
31 +
32 + var activeTab: Tab? {
33 + tabs.first { $0.id == activeTabID }
34 + }
35 +
36 + // MARK: - Opening
37 +
38 + /// Creates a new tab. If `url` is nil the tab opens the home page. When
39 + /// `select` is true it becomes the active tab.
40 + @discardableResult
41 + func newTab(url: URL? = nil, select: Bool = true) -> Tab {
42 + let tab = makeTab(configuration: nil)
43 + insert(tab, select: select)
44 + tab.load(url ?? Self.homeURL)
45 + return tab
46 + }
47 +
48 + /// Loads omnibox text in the active tab, creating one if none exists.
49 + func loadInActiveTab(_ text: String) {
50 + let tab = activeTab ?? newTab(url: nil, select: true)
51 + tab.loadOmnibox(text)
52 + }
53 +
54 + // MARK: - Closing / selecting
55 +
56 + func closeTab(_ id: Tab.ID) {
57 + guard let index = tabs.firstIndex(where: { $0.id == id }) else { return }
58 + let wasActive = (id == activeTabID)
59 + tabs.remove(at: index)
60 + if wasActive {
61 + // Prefer the tab that shifted into this slot, else the previous one.
62 + let next = tabs[safe: index] ?? tabs[safe: index - 1]
63 + activeTabID = next?.id
64 + }
65 + if tabs.isEmpty { newTab() }
66 + }
67 +
68 + func selectTab(_ id: Tab.ID) {
69 + activeTabID = id
70 + activeTab?.activate()
71 + suspendInactiveTabs()
72 + }
73 +
74 + func moveTab(from source: IndexSet, to destination: Int) {
75 + tabs.move(fromOffsets: source, toOffset: destination)
76 + }
77 +
78 + // MARK: - Suspension
79 +
80 + /// Suspends every non-active tab to reclaim memory (called after a switch).
81 + private func suspendInactiveTabs() {
82 + for tab in tabs where tab.id != activeTabID {
83 + tab.suspend()
84 + }
85 + }
86 +
87 + // MARK: - Tab factory + new-tab routing
88 +
89 + private func makeTab(configuration: WKWebViewConfiguration?) -> Tab {
90 + let tab = Tab(profile: profile, configuration: configuration)
91 + // Page-initiated new tab (target=_blank, window.open, ⌘-click): create a
92 + // sibling tab that reuses the page-supplied configuration and return its
93 + // web view so the page drives the load.
94 + tab.onCreateTab = { [weak self] config, request in
95 + guard let self else { return nil }
96 + let child = self.makeTab(configuration: config)
97 + self.insert(child, select: true)
98 + if let request { child.webView.load(request) }
99 + return child.webView
100 + }
101 + tab.onClose = { [weak self] in
102 + self?.closeTab(tab.id)
103 + }
104 + return tab
105 + }
106 +
107 + private func insert(_ tab: Tab, select: Bool) {
108 + tabs.append(tab)
109 + if select || activeTabID == nil {
110 + activeTabID = tab.id
111 + suspendInactiveTabs()
112 + }
113 + }
114 +}
115 +
116 +// MARK: - Safe indexing
117 +
118 +private extension Array {
119 + subscript(safe index: Int) -> Element? {
120 + indices.contains(index) ? self[index] : nil
121 + }
122 +}
added Sources/ZyquoAtlas/Browser/WebView.swift +52 −0
@@ -0,0 +1,52 @@
1 +//
2 +// WebView.swift
3 +// Zyquo Atlas
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// SwiftUI host for a Tab's WKWebView. Kept intentionally thin: navigation
9 +// state, delegates, and KVO live on the Tab; this only mounts the existing
10 +// web view into the view tree (WebKit's own out-of-process rendering keeps the
11 +// page off the main thread). Swapping the active tab swaps which web view is
12 +// hosted, so background tabs keep their live web views.
13 +//
14 +
15 +import SwiftUI
16 +import WebKit
17 +
18 +struct WebView: NSViewRepresentable {
19 + let tab: Tab
20 +
21 + func makeNSView(context: Context) -> ContainerView {
22 + let container = ContainerView()
23 + container.mount(tab.webView)
24 + return container
25 + }
26 +
27 + func updateNSView(_ container: ContainerView, context: Context) {
28 + // Re-mount only when the hosted web view actually changed (tab switch).
29 + if container.hostedWebView !== tab.webView {
30 + container.mount(tab.webView)
31 + }
32 + }
33 +
34 + /// A plain container that pins a single WKWebView to its bounds.
35 + final class ContainerView: NSView {
36 + private(set) weak var hostedWebView: WKWebView?
37 +
38 + func mount(_ webView: WKWebView) {
39 + guard hostedWebView !== webView else { return }
40 + hostedWebView?.removeFromSuperview()
41 + webView.translatesAutoresizingMaskIntoConstraints = false
42 + addSubview(webView)
43 + NSLayoutConstraint.activate([
44 + webView.topAnchor.constraint(equalTo: topAnchor),
45 + webView.bottomAnchor.constraint(equalTo: bottomAnchor),
46 + webView.leadingAnchor.constraint(equalTo: leadingAnchor),
47 + webView.trailingAnchor.constraint(equalTo: trailingAnchor),
48 + ])
49 + hostedWebView = webView
50 + }
51 + }
52 +}
added Sources/ZyquoAtlas/DesignSystem/ZyquoTheme.swift +130 −0
@@ -0,0 +1,130 @@
1 +//
2 +// ZyquoTheme.swift
3 +// Zyquo Atlas
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
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.
14 +//
15 +
16 +import SwiftUI
17 +
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 {
54 + /// 0xRRGGBB → NSColor (sRGB).
55 + convenience init(hex: UInt32) {
56 + self.init(
57 + srgbRed: CGFloat((hex >> 16) & 0xFF) / 255,
58 + green: CGFloat((hex >> 8) & 0xFF) / 255,
59 + blue: CGFloat(hex & 0xFF) / 255,
60 + alpha: 1
61 + )
62 + }
63 +}
64 +
65 +// MARK: - Typography
66 +
67 +/// Type scale (SF Pro system font; SF Mono for code). Body is 13.5pt/1.45 per
68 +/// the family spec.
69 +enum ZyquoFont {
70 + /// 20pt semibold — window/section titles.
71 + static let title = Font.system(size: 20, weight: .semibold)
72 + /// UI body at a given size (default 13.5).
73 + static func body(size: Double = 13.5) -> Font {
74 + .system(size: size, weight: .regular)
75 + }
76 + static func bodyEmphasis(size: Double = 13.5) -> Font {
77 + .system(size: size, weight: .medium)
78 + }
79 + /// 12.5pt medium — omnibox / tab titles.
80 + static let control = Font.system(size: 12.5, weight: .medium)
81 + /// 11pt — captions, status.
82 + static let caption = Font.system(size: 11, weight: .regular)
83 + static func code(size: Double = 12.5) -> Font {
84 + .system(size: size, weight: .regular, design: .monospaced)
85 + }
86 + /// Generous line height for body text (spec: 1.45 → factor 0.45).
87 + static let bodyLineSpacingFactor: Double = 0.45
88 +}
89 +
90 +// MARK: - Spacing, radii, shadows, metrics
91 +
92 +/// Spacing scale: 4 / 8 / 12 / 16 / 20 / 24 / 32.
93 +enum ZyquoSpacing {
94 + static let xxs: CGFloat = 4
95 + static let xs: CGFloat = 8
96 + static let sm: CGFloat = 12
97 + static let md: CGFloat = 16
98 + static let lg: CGFloat = 20
99 + static let xl: CGFloat = 24
100 + static let xxl: CGFloat = 32
101 +}
102 +
103 +/// Corner radii: 6 (small controls), 10 (cards/tabs), 14 (floating panels).
104 +enum ZyquoRadius {
105 + static let small: CGFloat = 6
106 + static let medium: CGFloat = 10
107 + static let large: CGFloat = 14
108 +}
109 +
110 +/// Shadows: extremely soft, used sparingly (floating panels, popovers).
111 +enum ZyquoShadow {
112 + static let soft = ShadowStyle(color: .black.opacity(0.06), radius: 12, y: 2)
113 +
114 + struct ShadowStyle {
115 + let color: Color
116 + let radius: CGFloat
117 + var x: CGFloat = 0
118 + var y: CGFloat = 0
119 + }
120 +}
121 +
122 +/// Fixed layout metrics (chrome must be quiet so web content is the star).
123 +enum ZyquoMetrics {
124 + static let toolbarHeight: CGFloat = 44
125 + static let tabBarHeight: CGFloat = 36
126 + static let tabMinWidth: CGFloat = 120
127 + static let tabMaxWidth: CGFloat = 240
128 + static let aiSidebarWidth: CGFloat = 360
129 + static let hairline: CGFloat = 0.5
130 +}
added Sources/ZyquoAtlas/Models/Profile.swift +41 −0
@@ -0,0 +1,41 @@
1 +//
2 +// Profile.swift
3 +// Zyquo Atlas
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// A browsing profile: an identity with its own website data store (cookies,
9 +// cache, local storage). Private profiles use a non-persistent store so no
10 +// browsing data touches disk. Phase 4 gives each profile its own theme,
11 +// favorites, and tabs; Phase 2 needs the identity + data-store distinction.
12 +//
13 +
14 +import Foundation
15 +
16 +struct Profile: Identifiable, Hashable {
17 + let id: UUID
18 + var name: String
19 + /// When true, browsing uses a non-persistent data store (incognito).
20 + let isPrivate: Bool
21 +
22 + init(id: UUID = UUID(), name: String, isPrivate: Bool = false) {
23 + self.id = id
24 + self.name = name
25 + self.isPrivate = isPrivate
26 + }
27 +
28 + /// The default persistent profile created on first launch.
29 + static let defaultProfile = Profile(
30 + id: UUID(uuidString: "00000000-0000-0000-0000-0000000A71A5")!,
31 + name: "Personal",
32 + isPrivate: false
33 + )
34 +
35 + /// The shared private profile (non-persistent).
36 + static let privateProfile = Profile(
37 + id: UUID(uuidString: "00000000-0000-0000-0000-0000000B1A2E")!,
38 + name: "Private",
39 + isPrivate: true
40 + )
41 +}
added Sources/ZyquoAtlas/Models/Tab.swift +172 −0
@@ -0,0 +1,172 @@
1 +//
2 +// Tab.swift
3 +// Zyquo Atlas
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// A browser tab. Owns one WKWebView and publishes its navigation state (URL,
9 +// title, progress, back/forward, loading, favicon) via KVO so SwiftUI chrome
10 +// updates live. Acts as the web view's navigation + UI delegate: link opens
11 +// that request a new web view (target=_blank, ⌘-click) are routed back to the
12 +// TabManager. Each tab owns its own future AI context (Phase 3); background
13 +// tabs can be suspended to reclaim memory.
14 +//
15 +
16 +import Foundation
17 +import WebKit
18 +import Combine
19 +
20 +@MainActor
21 +final class Tab: NSObject, ObservableObject, Identifiable {
22 + let id = UUID()
23 + let profile: Profile
24 +
25 + // Published navigation state (chrome binds to these).
26 + @Published private(set) var url: URL?
27 + @Published var title: String = "New Tab"
28 + @Published private(set) var estimatedProgress: Double = 0
29 + @Published private(set) var isLoading: Bool = false
30 + @Published private(set) var canGoBack: Bool = false
31 + @Published private(set) var canGoForward: Bool = false
32 + @Published private(set) var hasSecureConnection: Bool = false
33 + @Published private(set) var isSuspended: Bool = false
34 + /// True while a determinate load is in flight (drives the progress bar).
35 + @Published private(set) var showsProgress: Bool = false
36 +
37 + /// Called when the page requests a new web view (new tab / ⌘-click).
38 + /// Returns the web view the new tab will drive, or nil to block.
39 + var onCreateTab: ((WKWebViewConfiguration, URLRequest?) -> WKWebView?)?
40 + /// Called when the tab wants to be closed (window.close()).
41 + var onClose: (() -> Void)?
42 +
43 + private(set) var webView: WKWebView!
44 + private var observations: [NSKeyValueObservation] = []
45 + /// Restored on un-suspend.
46 + private var suspendedURL: URL?
47 +
48 + init(profile: Profile = .defaultProfile,
49 + configuration: WKWebViewConfiguration? = nil) {
50 + self.profile = profile
51 + super.init()
52 + let config = configuration ?? ProfileStore.shared.makeConfiguration(for: profile)
53 + let webView = WKWebView(frame: .zero, configuration: config)
54 + webView.navigationDelegate = self
55 + webView.uiDelegate = self
56 + webView.allowsBackForwardNavigationGestures = true
57 + webView.allowsMagnification = true
58 + webView.customUserAgent = nil // use WebKit's default (Safari-compatible)
59 + self.webView = webView
60 + installObservers()
61 + }
62 +
63 + // MARK: - Navigation commands
64 +
65 + func load(_ url: URL) {
66 + unsuspendIfNeeded()
67 + webView.load(URLRequest(url: url))
68 + }
69 +
70 + func loadOmnibox(_ text: String) {
71 + if let url = OmniIntent.resolve(text).url { load(url) }
72 + }
73 +
74 + func goBack() { webView.goBack() }
75 + func goForward() { webView.goForward() }
76 + func reload() { webView.reload() }
77 + func stop() { webView.stopLoading() }
78 +
79 + // MARK: - Suspension (memory saving for background tabs)
80 +
81 + /// Tears down the web content while remembering the current URL, freeing
82 + /// the content process. Restored on next activation.
83 + func suspend() {
84 + guard !isSuspended, let current = url else { return }
85 + suspendedURL = current
86 + webView.loadHTMLString("", baseURL: nil)
87 + isSuspended = true
88 + }
89 +
90 + private func unsuspendIfNeeded() {
91 + guard isSuspended, let saved = suspendedURL else { return }
92 + isSuspended = false
93 + suspendedURL = nil
94 + webView.load(URLRequest(url: saved))
95 + }
96 +
97 + func activate() { unsuspendIfNeeded() }
98 +
99 + // MARK: - KVO wiring
100 +
101 + private func installObservers() {
102 + observations = [
103 + webView.observe(\.estimatedProgress, options: [.new]) { [weak self] wv, _ in
104 + Task { @MainActor in self?.estimatedProgress = wv.estimatedProgress }
105 + },
106 + webView.observe(\.isLoading, options: [.new]) { [weak self] wv, _ in
107 + Task { @MainActor in
108 + self?.isLoading = wv.isLoading
109 + self?.showsProgress = wv.isLoading && wv.estimatedProgress < 1
110 + }
111 + },
112 + webView.observe(\.url, options: [.new]) { [weak self] wv, _ in
113 + Task { @MainActor in
114 + self?.url = wv.url
115 + self?.hasSecureConnection = wv.url?.scheme?.lowercased() == "https"
116 + }
117 + },
118 + webView.observe(\.title, options: [.new]) { [weak self] wv, _ in
119 + Task { @MainActor in
120 + let t = wv.title ?? ""
121 + self?.title = t.isEmpty ? (wv.url?.host ?? "New Tab") : t
122 + }
123 + },
124 + webView.observe(\.canGoBack, options: [.new]) { [weak self] wv, _ in
125 + Task { @MainActor in self?.canGoBack = wv.canGoBack }
126 + },
127 + webView.observe(\.canGoForward, options: [.new]) { [weak self] wv, _ in
128 + Task { @MainActor in self?.canGoForward = wv.canGoForward }
129 + },
130 + ]
131 + }
132 +
133 + deinit { observations.forEach { $0.invalidate() } }
134 +}
135 +
136 +// MARK: - WKNavigationDelegate
137 +
138 +extension Tab: WKNavigationDelegate {
139 + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
140 + showsProgress = true
141 + }
142 +
143 + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
144 + showsProgress = false
145 + estimatedProgress = 1
146 + }
147 +
148 + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
149 + showsProgress = false
150 + }
151 +
152 + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
153 + showsProgress = false
154 + }
155 +}
156 +
157 +// MARK: - WKUIDelegate (new tabs / window.close)
158 +
159 +extension Tab: WKUIDelegate {
160 + func webView(_ webView: WKWebView,
161 + createWebViewWith configuration: WKWebViewConfiguration,
162 + for navigationAction: WKNavigationAction,
163 + windowFeatures: WKWindowFeatures) -> WKWebView? {
164 + // target=_blank, window.open, ⌘-click → open in a new tab and hand the
165 + // page the new tab's web view so it drives the load.
166 + onCreateTab?(configuration, navigationAction.request)
167 + }
168 +
169 + func webViewDidClose(_ webView: WKWebView) {
170 + onClose?()
171 + }
172 +}
added Sources/ZyquoAtlas/Views/Browser/BrowserWindowView.swift +51 −0
@@ -0,0 +1,51 @@
1 +//
2 +// BrowserWindowView.swift
3 +// Zyquo Atlas
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
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.
12 +//
13 +
14 +import SwiftUI
15 +
16 +struct BrowserWindowView: View {
17 + @StateObject private var tabManager = TabManager(profile: .defaultProfile)
18 +
19 + var body: some View {
20 + VStack(spacing: 0) {
21 + TabBarView(tabManager: tabManager)
22 +
23 + if let tab = tabManager.activeTab {
24 + ToolbarView(
25 + tab: tab,
26 + onSubmit: { tabManager.loadInActiveTab($0) },
27 + onNewTab: { tabManager.newTab() }
28 + )
29 + WebContentArea(tab: tab)
30 + } else {
31 + Spacer()
32 + }
33 + }
34 + .background(ZyquoColor.background)
35 + .frame(minWidth: 900, minHeight: 600)
36 + .onAppear {
37 + if tabManager.tabs.isEmpty { tabManager.newTab() }
38 + }
39 + }
40 +}
41 +
42 +/// Hosts the active tab's web view. Keyed by tab id so SwiftUI re-mounts the
43 +/// correct WKWebView on a tab switch while background tabs keep theirs alive.
44 +private struct WebContentArea: View {
45 + @ObservedObject var tab: Tab
46 +
47 + var body: some View {
48 + WebView(tab: tab)
49 + .id(tab.id)
50 + }
51 +}
added Sources/ZyquoAtlas/Views/Browser/OmniboxView.swift +90 −0
@@ -0,0 +1,90 @@
1 +//
2 +// OmniboxView.swift
3 +// Zyquo Atlas
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// The address/search field. Shows a security indicator, reflects the active
9 +// tab's URL when not being edited, and on submit resolves URL-vs-search via
10 +// OmniIntent. The Phase 3 "ask AI" route and answer overlay attach here.
11 +//
12 +
13 +import SwiftUI
14 +
15 +struct OmniboxView: View {
16 + @ObservedObject var tab: Tab
17 + let onSubmit: (String) -> Void
18 +
19 + @State private var text: String = ""
20 + @State private var isEditing: Bool = false
21 + @FocusState private var focused: Bool
22 +
23 + var body: some View {
24 + HStack(spacing: ZyquoSpacing.xs) {
25 + Image(systemName: securityGlyph)
26 + .font(.system(size: 11, weight: .semibold))
27 + .foregroundStyle(securityColor)
28 +
29 + TextField("Search or enter website name", text: $text)
30 + .textFieldStyle(.plain)
31 + .font(ZyquoFont.control)
32 + .foregroundStyle(ZyquoColor.textPrimary)
33 + .focused($focused)
34 + .onSubmit {
35 + onSubmit(text)
36 + focused = false
37 + }
38 + .onChange(of: focused) { now in
39 + isEditing = now
40 + if now { selectAll() } else { syncFromTab() }
41 + }
42 +
43 + if isEditing && !text.isEmpty {
44 + Button {
45 + text = ""
46 + } label: {
47 + Image(systemName: "xmark.circle.fill")
48 + .font(.system(size: 12))
49 + .foregroundStyle(ZyquoColor.textTertiary)
50 + }
51 + .buttonStyle(.plain)
52 + }
53 + }
54 + .padding(.horizontal, ZyquoSpacing.sm)
55 + .frame(height: 30)
56 + .background(
57 + RoundedRectangle(cornerRadius: ZyquoRadius.small)
58 + .fill(ZyquoColor.surfaceSecondary)
59 + )
60 + .overlay(
61 + RoundedRectangle(cornerRadius: ZyquoRadius.small)
62 + .strokeBorder(focused ? ZyquoColor.accent : ZyquoColor.border,
63 + lineWidth: focused ? 1.5 : ZyquoMetrics.hairline)
64 + )
65 + .onChange(of: tab.url) { _ in if !isEditing { syncFromTab() } }
66 + .onAppear { syncFromTab() }
67 + }
68 +
69 + // MARK: - Helpers
70 +
71 + private var securityGlyph: String {
72 + guard tab.url != nil else { return "magnifyingglass" }
73 + return tab.hasSecureConnection ? "lock.fill" : "exclamationmark.triangle.fill"
74 + }
75 +
76 + private var securityColor: Color {
77 + guard tab.url != nil else { return ZyquoColor.textTertiary }
78 + return tab.hasSecureConnection ? ZyquoColor.textSecondary : ZyquoColor.warning
79 + }
80 +
81 + private func syncFromTab() {
82 + text = tab.url?.absoluteString ?? ""
83 + }
84 +
85 + private func selectAll() {
86 + // Reflect the raw URL for editing; SwiftUI selects on focus via the
87 + // field's default behavior when text is present.
88 + if text.isEmpty { syncFromTab() }
89 + }
90 +}
added Sources/ZyquoAtlas/Views/Browser/TabBarView.swift +99 −0
@@ -0,0 +1,99 @@
1 +//
2 +// TabBarView.swift
3 +// Zyquo Atlas
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Top horizontal tab strip. Each tab shows its title (and a spinner while
9 +// loading), the active tab is tinted with the accent, and hovering reveals a
10 +// close button. Phase 4 adds the vertical/left layout option, drag-reorder,
11 +// pinning, and hover previews; this is the top-bar baseline.
12 +//
13 +
14 +import SwiftUI
15 +
16 +struct TabBarView: View {
17 + @ObservedObject var tabManager: TabManager
18 +
19 + var body: some View {
20 + ScrollView(.horizontal, showsIndicators: false) {
21 + HStack(spacing: ZyquoSpacing.xxs) {
22 + ForEach(tabManager.tabs) { tab in
23 + TabChip(
24 + tab: tab,
25 + isActive: tab.id == tabManager.activeTabID,
26 + onSelect: { tabManager.selectTab(tab.id) },
27 + onClose: { tabManager.closeTab(tab.id) }
28 + )
29 + }
30 + }
31 + .padding(.horizontal, ZyquoSpacing.xs)
32 + }
33 + .frame(height: ZyquoMetrics.tabBarHeight)
34 + .background(ZyquoColor.background)
35 + }
36 +}
37 +
38 +private struct TabChip: View {
39 + @ObservedObject var tab: Tab
40 + let isActive: Bool
41 + let onSelect: () -> Void
42 + let onClose: () -> Void
43 +
44 + @State private var hovering = false
45 +
46 + var body: some View {
47 + HStack(spacing: ZyquoSpacing.xs) {
48 + leading
49 + .frame(width: 14, height: 14)
50 +
51 + Text(tab.title)
52 + .font(ZyquoFont.control)
53 + .lineLimit(1)
54 + .foregroundStyle(isActive ? ZyquoColor.textPrimary : ZyquoColor.textSecondary)
55 +
56 + Spacer(minLength: 0)
57 +
58 + if hovering || isActive {
59 + Button(action: onClose) {
60 + Image(systemName: "xmark")
61 + .font(.system(size: 9, weight: .bold))
62 + .frame(width: 16, height: 16)
63 + .contentShape(Rectangle())
64 + }
65 + .buttonStyle(.plain)
66 + .foregroundStyle(ZyquoColor.textTertiary)
67 + }
68 + }
69 + .padding(.horizontal, ZyquoSpacing.sm)
70 + .frame(width: ZyquoMetrics.tabMinWidth, height: ZyquoMetrics.tabBarHeight - 6)
71 + .background(
72 + RoundedRectangle(cornerRadius: ZyquoRadius.small)
73 + .fill(isActive ? ZyquoColor.accentSubtle
74 + : (hovering ? ZyquoColor.surfaceSecondary : .clear))
75 + )
76 + .overlay(alignment: .bottom) {
77 + if isActive {
78 + Rectangle().fill(ZyquoColor.accent).frame(height: 2)
79 + .padding(.horizontal, ZyquoSpacing.xs)
80 + }
81 + }
82 + .contentShape(Rectangle())
83 + .onTapGesture(perform: onSelect)
84 + .onHover { hovering = $0 }
85 + }
86 +
87 + @ViewBuilder
88 + private var leading: some View {
89 + if tab.isLoading {
90 + ProgressView()
91 + .controlSize(.mini)
92 + .scaleEffect(0.7)
93 + } else {
94 + Image(systemName: "globe")
95 + .font(.system(size: 10))
96 + .foregroundStyle(ZyquoColor.textTertiary)
97 + }
98 + }
99 +}
added Sources/ZyquoAtlas/Views/Browser/ToolbarView.swift +78 −0
@@ -0,0 +1,78 @@
1 +//
2 +// ToolbarView.swift
3 +// Zyquo Atlas
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// The unified browser toolbar: back / forward / reload-or-stop, the omnibox,
9 +// and a new-tab button. A determinate progress bar hairline sits under the
10 +// toolbar while a page loads. The AI panel toggle and profile switcher attach
11 +// here in later phases.
12 +//
13 +
14 +import SwiftUI
15 +
16 +struct ToolbarView: View {
17 + @ObservedObject var tab: Tab
18 + let onSubmit: (String) -> Void
19 + let onNewTab: () -> Void
20 +
21 + var body: some View {
22 + VStack(spacing: 0) {
23 + HStack(spacing: ZyquoSpacing.xs) {
24 + navButton("chevron.backward", enabled: tab.canGoBack) { tab.goBack() }
25 + navButton("chevron.forward", enabled: tab.canGoForward) { tab.goForward() }
26 + if tab.isLoading {
27 + navButton("xmark", enabled: true) { tab.stop() }
28 + } else {
29 + navButton("arrow.clockwise", enabled: tab.url != nil) { tab.reload() }
30 + }
31 +
32 + OmniboxView(tab: tab, onSubmit: onSubmit)
33 + .frame(maxWidth: .infinity)
34 +
35 + navButton("plus", enabled: true, action: onNewTab)
36 + }
37 + .padding(.horizontal, ZyquoSpacing.sm)
38 + .frame(height: ZyquoMetrics.toolbarHeight)
39 +
40 + progressBar
41 + }
42 + .background(ZyquoColor.surface)
43 + .overlay(alignment: .bottom) {
44 + Rectangle()
45 + .fill(ZyquoColor.border)
46 + .frame(height: ZyquoMetrics.hairline)
47 + }
48 + }
49 +
50 + // MARK: - Progress
51 +
52 + @ViewBuilder
53 + private var progressBar: some View {
54 + GeometryReader { geo in
55 + if tab.showsProgress {
56 + Rectangle()
57 + .fill(ZyquoColor.accent)
58 + .frame(width: geo.size.width * max(0.02, tab.estimatedProgress))
59 + .animation(.easeOut(duration: 0.2), value: tab.estimatedProgress)
60 + }
61 + }
62 + .frame(height: 2)
63 + }
64 +
65 + // MARK: - Buttons
66 +
67 + private func navButton(_ symbol: String, enabled: Bool, action: @escaping () -> Void) -> some View {
68 + Button(action: action) {
69 + Image(systemName: symbol)
70 + .font(.system(size: 13, weight: .medium))
71 + .frame(width: 28, height: 28)
72 + .contentShape(Rectangle())
73 + }
74 + .buttonStyle(.plain)
75 + .foregroundStyle(enabled ? ZyquoColor.textSecondary : ZyquoColor.textTertiary.opacity(0.5))
76 + .disabled(!enabled)
77 + }
78 +}
deleted Sources/ZyquoAtlas/Views/RootPlaceholderView.swift +0 −34
@@ -1,34 +0,0 @@
1 //
2 // RootPlaceholderView.swift
3 // Zyquo Atlas
4 //
5 // Author: Simon-Pierre Boucher
6 // Mail: contact@spboucher.ai
7 //
8 // Phase 1 placeholder root. Confirms the app builds, launches, and renders a
9 // window. Replaced in Phase 2 by the real browser window (BrowserWindowView).
10 //
11
12 import SwiftUI
13
14 struct RootPlaceholderView: View {
15 var body: some View {
16 ZStack {
17 LinearGradient(
18 colors: [Color(red: 0.13, green: 0.66, blue: 0.63),
19 Color(red: 0.31, green: 0.39, blue: 0.88)],
20 startPoint: .top, endPoint: .bottom
21 )
22 .ignoresSafeArea()
23
24 VStack(spacing: 12) {
25 Text("Zyquo Atlas")
26 .font(.system(size: 40, weight: .bold, design: .rounded))
27 .foregroundStyle(.white)
28 Text("AI-native macOS browser — Phase 1 scaffold")
29 .font(.system(size: 14, weight: .medium))
30 .foregroundStyle(.white.opacity(0.85))
31 }
32 }
33 }
34 }
added Tests/ZyquoAtlasTests/OmniIntentTests.swift +48 −0
@@ -0,0 +1,48 @@
1 +//
2 +// OmniIntentTests.swift
3 +// Zyquo Atlas
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Verifies the Phase 2 omnibox URL-vs-search resolution (the phase gate's
9 +// "omnibox must resolve URL vs. search" requirement).
10 +//
11 +
12 +import Foundation
13 +import Testing
14 +@testable import ZyquoAtlas
15 +
16 +@Suite struct OmniIntentTests {
17 + @Test func explicitSchemeNavigates() {
18 + #expect(OmniIntent.resolve("https://apple.com") == .navigate(URL(string: "https://apple.com")!))
19 + #expect(OmniIntent.resolve("http://example.org/path") == .navigate(URL(string: "http://example.org/path")!))
20 + }
21 +
22 + @Test func bareHostBecomesHTTPS() {
23 + #expect(OmniIntent.resolve("github.com") == .navigate(URL(string: "https://github.com")!))
24 + #expect(OmniIntent.resolve("news.ycombinator.com/newest")
25 + == .navigate(URL(string: "https://news.ycombinator.com/newest")!))
26 + }
27 +
28 + @Test func localhostAndIPNavigate() {
29 + #expect(OmniIntent.resolve("localhost:8080") == .navigate(URL(string: "https://localhost:8080")!))
30 + #expect(OmniIntent.resolve("127.0.0.1") == .navigate(URL(string: "https://127.0.0.1")!))
31 + }
32 +
33 + @Test func multiWordOrPlainTextSearches() {
34 + #expect(OmniIntent.resolve("best coffee in montreal") == .search("best coffee in montreal"))
35 + #expect(OmniIntent.resolve("swift concurrency") == .search("swift concurrency"))
36 + }
37 +
38 + @Test func singleWordWithoutTLDSearches() {
39 + #expect(OmniIntent.resolve("swift") == .search("swift"))
40 + }
41 +
42 + @Test func searchIntentProducesQueryURL() {
43 + let intent = OmniIntent.resolve("hello world")
44 + let url = intent.url
45 + #expect(url != nil)
46 + #expect(url!.absoluteString.contains("hello%20world"))
47 + }
48 +}
modified docs/PLAN.md +14 −0
@@ -72,3 +72,17 @@ loads only inside WKWebView content). `@main` uses a synchronous entry with a `-
72 72 Phase 7 harness can run headlessly on the ported provider clients. Verify/test targets and a CLT-only
73 73 test runner are wired. All files carry the mandatory header. **Phase 1 gate PASSED** (buildable,
74 74 launchable, single window; browser core is Phase 2).
75 +
76 +## Phase 2 — Architecture + Browser Core — COMPLETE
77 +
78 +- [x] `DesignSystem/ZyquoTheme.swift` — Atlas teal-indigo tokens (Phase 4.1 base): background/surface/accent #1FA9A0 + indigo #4E63E0/accentSubtle/text/border/status; typography 13.5/1.45, spacing 4–32, radii 6/10/14; light flagship + dark derived via dynamic NSColor
79 +- [x] `Models/Profile.swift` + `Browser/ProfileStore.swift` — profile identity; per-profile WKWebsiteDataStore (persistent normal / non-persistent private); modern WebKit auto-pools processes (no WKProcessPool)
80 +- [x] `Models/Tab.swift` — owns one WKWebView, KVO-publishes url/title/progress/loading/back/forward/secure; WKNavigationDelegate (progress lifecycle) + WKUIDelegate (new tab on target=_blank/⌘-click, window.close); background-tab suspend/restore
81 +- [x] `Browser/TabManager.swift` — ObservableObject tabs + activeTabID; new/close/select/reorder; routes page-initiated new tabs; suspends inactive tabs
82 +- [x] `Browser/OmniIntent.swift` — resolves omnibox text: explicit scheme → navigate, bare host/localhost/IP → https navigate, else web search (URL-vs-search gate)
83 +- [x] `Browser/WebView.swift` — thin NSViewRepresentable hosting the active tab's WKWebView (re-mounts on switch; background tabs keep live web views)
84 +- [x] Views: `BrowserWindowView` (tab strip + toolbar + content), `ToolbarView` (back/forward/reload-or-stop/omnibox/new-tab + determinate progress bar), `OmniboxView` (security glyph + URL/search), `TabBarView` (active tint, hover close, loading spinner)
85 +- [x] Clean release build (zero warnings; fixed macOS-14 onChange → 13 form, removed deprecated WKProcessPool), `make dev` launches
86 +- [x] 7 tests green (OmniIntent URL-vs-search + smoke); header sweep + coherence pass; folders match Phase 2 layout
87 +
88 +**PHASE GATE verified (2026-07-30):** launched `Zyquo Atlas.app`; a tab navigated to and **rendered the live DuckDuckGo page** (screenshot), omnibox showed `🔒 https://duckduckgo.com/` with lock indicator, back/forward/reload/stop + new-tab controls present, titled closable tab chip, links open in new tabs via WKUIDelegate, omnibox resolves URL vs search (unit-tested). **Phase 2 gate PASSED** — ready for Phase 3 (AI everywhere). Note: an AppleScript `quit`-by-name collision caused one benign clean exit during testing (no crash log); app is stable on normal launch.
75 89