// // BrowserView.swift // Prisme // // Author: Simon-Pierre Boucher // import SwiftUI /// Main screen: the web content fills the screen, a floating glass chrome /// sits at the bottom. The page always renders immediately — nothing in the /// chrome may ever block it (CLAUDE.md §8). struct BrowserView: View { let model: BrowserModel @State private var showTabSwitcher = false @State private var showReader = false @State private var showLibrary = false var body: some View { ZStack(alignment: .bottom) { content .ignoresSafeArea(edges: .bottom) chrome } .sheet(isPresented: $showTabSwitcher) { TabSwitcherView(model: model) } .fullScreenCover(isPresented: $showReader) { if let tab = model.tabs.activeTab { ReaderView( tab: tab, intelligence: model.intelligence, accent: model.activeContainer.color.color, library: model.library, onSaveFavorite: { model.saveFavorite(for: tab) } ) } } .sheet(isPresented: $showLibrary) { LibraryView( library: model.library, containers: model.containers, onOpen: { url, containerID in model.selectContainer(containerID) model.submit(.navigate(url)) } ) } } @ViewBuilder private var content: some View { if let tab = model.tabs.activeTab, !tab.isBlank { BrowserWebView(tab: tab, pool: model.pool) .id(tab.id) .transition(.opacity) } else { StartPageView( active: model.activeContainer, containers: model.containers.all, onSelect: { model.selectContainer($0) } ) } } // MARK: - Chrome private var chrome: some View { VStack(spacing: Spacing.m) { if let tab = model.tabs.activeTab, let insight = tab.insight, !tab.insightDismissed, insight.url == tab.page.url { InsightStrip( insight: insight, digest: readyDigest(for: tab), accent: model.activeContainer.color.color, onRead: { showReader = true }, onDismiss: { withAnimation(.spring(duration: 0.3)) { tab.insightDismissed = true } } ) .transition(.move(edge: .bottom).combined(with: .opacity)) } if let notice = model.notice { Text(notice) .font(.footnote.weight(.medium)) .padding(.horizontal, Spacing.l) .padding(.vertical, Spacing.s) .glassEffect(.regular, in: Capsule()) .transition(.move(edge: .bottom).combined(with: .opacity)) } AddressBar( page: model.tabs.activeTab?.page, accent: model.activeContainer.color.color, history: model.history, onIntent: { model.submit($0) }, onRecall: { model.open($0) }, onOpenReader: { showReader = true } ) .id(model.tabs.activeTabID) toolbar } .padding(.horizontal, Spacing.l) .padding(.bottom, Spacing.s) .animation(.spring(duration: 0.3), value: model.notice) .animation(.spring(duration: 0.35), value: model.tabs.activeTab?.insight) } private func readyDigest(for tab: Tab) -> PageDigest? { if case .ready(let digest, _) = model.intelligence.digestState(for: tab) { return digest } return nil } private var toolbar: some View { HStack(spacing: 0) { toolbarButton("chevron.left", disabled: model.tabs.activeTab?.page.canGoBack != true) { model.tabs.activeTab?.page.goBack() } toolbarButton("chevron.right", disabled: model.tabs.activeTab?.page.canGoForward != true) { model.tabs.activeTab?.page.goForward() } Spacer(minLength: 0) containerMenu Spacer(minLength: 0) toolbarButton("books.vertical") { showLibrary = true } toolbarButton("plus") { withAnimation(.spring(duration: 0.3)) { model.newTab() } } tabCountButton } .padding(.horizontal, Spacing.s) .frame(height: 54) .glassEffect(.regular, in: Capsule()) } private func toolbarButton( _ symbol: String, disabled: Bool = false, action: @escaping () -> Void ) -> some View { Button(action: action) { Image(systemName: symbol) .font(.title3.weight(.medium)) .frame(width: 52, height: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) .foregroundStyle(disabled ? Color.secondary.opacity(0.4) : Color.primary) .disabled(disabled) } private var tabCountButton: some View { Button { showTabSwitcher = true } label: { let count = model.tabs.tabs(in: model.activeContainerID).count ZStack { RoundedRectangle(cornerRadius: 7) .strokeBorder(Color.primary, lineWidth: 2) .frame(width: 24, height: 24) Text(count > 0 ? "\(count)" : "") .font(.caption.bold()) .monospacedDigit() } .frame(width: 52, height: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) } /// One gesture to change universe (identity container, P0). private var containerMenu: some View { Menu { ForEach(model.containers.all) { container in Button { withAnimation(.spring(duration: 0.3)) { model.selectContainer(container.id) } } label: { Label(container.name, systemImage: container.symbol) } } } label: { Image(systemName: model.activeContainer.symbol) .font(.subheadline.weight(.semibold)) .foregroundStyle(.white) .frame(width: 40, height: 40) .background(model.activeContainer.color.gradient, in: Circle()) } .buttonStyle(.plain) .accessibilityLabel(model.activeContainer.name) } } // MARK: - Start page /// Shown when the active tab has nothing to render yet: the brand mark and /// a one-tap switch between identity universes. struct StartPageView: View { let active: IdentityContainer let containers: [IdentityContainer] let onSelect: (IdentityContainer.ID) -> Void var body: some View { ZStack { LinearGradient( colors: [ active.color.color.opacity(0.28), active.color.color.opacity(0.06), Color(.systemBackground), ], startPoint: .top, endPoint: .bottom ) .ignoresSafeArea() VStack(spacing: Spacing.l) { Spacer() PrismMark() .frame(width: 170, height: 170) Text("Prisme") .font(.system(size: 46, weight: .bold, design: .rounded)) Text("Un univers « \(active.name) » — cookies, sessions et empreinte y sont isolés du reste de vos vies.") .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, Spacing.xl * 2) containerChips .padding(.top, Spacing.m) Spacer() Spacer() } } } private var containerChips: some View { HStack(spacing: Spacing.s) { ForEach(containers) { container in let isActive = container.id == active.id Button { withAnimation(.spring(duration: 0.3)) { onSelect(container.id) } } label: { HStack(spacing: Spacing.xs) { Image(systemName: container.symbol) .font(.caption.weight(.semibold)) if isActive { Text(container.name) .font(.caption.weight(.semibold)) .lineLimit(1) .fixedSize() } } .foregroundStyle(isActive ? .white : container.color.color) .padding(.horizontal, isActive ? Spacing.l : Spacing.m) .padding(.vertical, Spacing.m) .background { if isActive { Capsule().fill(container.color.gradient) } else { Capsule().fill(container.color.color.opacity(0.14)) } } } .buttonStyle(.plain) } } .animation(.spring(duration: 0.3), value: active.id) } }