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>
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 | |