SPB Git

spb/zyquo-atlas

Public License

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

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 (Jul 30, 2026) parent 4dff8ef Browse files

15 changed files +1,034 −36

modified Sources/ZyquoAtlas/App/ZyquoAtlasApp.swift +1 −2
@@ -18,8 +18,7 @@ struct ZyquoAtlasApp: App {
18 18
19 var body: some Scene { 19 var body: some Scene {
20 WindowGroup("Zyquo Atlas") { 20 WindowGroup("Zyquo Atlas") {
21− RootPlaceholderView() 21+ BrowserWindowView()
22− .frame(minWidth: 900, minHeight: 600)
23 } 22 }
24 .windowStyle(.hiddenTitleBar) 23 .windowStyle(.hiddenTitleBar)
25 .windowToolbarStyle(.unified) 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 Phase 7 harness can run headlessly on the ported provider clients. Verify/test targets and a CLT-only 72 Phase 7 harness can run headlessly on the ported provider clients. Verify/test targets and a CLT-only
73 test runner are wired. All files carry the mandatory header. **Phase 1 gate PASSED** (buildable, 73 test runner are wired. All files carry the mandatory header. **Phase 1 gate PASSED** (buildable,
74 launchable, single window; browser core is Phase 2). 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
Menu

Theme