// // ReaderView.swift // Zyquo Atlas // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Reader mode: a clean, themeable article view built from the tab's extracted // PageContext (Readability), with an optional AI summary at the top. Reader // typography (measure, size, spacing) follows the theme; honest fallback when // the page isn't an article. // import SwiftUI struct ReaderView: View { @ObservedObject var tab: Tab @EnvironmentObject private var env: AppEnvironment @EnvironmentObject private var themeEngine: ThemeEngine private var theme: AtlasTheme { themeEngine.theme } @State private var context: PageContext? @State private var loadError: String? @StateObject private var ai = AIService() var body: some View { ScrollView { VStack(alignment: .leading, spacing: ZyquoSpacing.md) { if let ctx = context { Text(ctx.title).font(.system(size: 30 * themeEngine.layout.uiScale, weight: .bold)) .foregroundStyle(theme.textPrimary) if let byline = ctx.byline { Text(byline).font(ZyquoFont.caption).foregroundStyle(theme.textSecondary) } summaryCard(ctx) Divider().overlay(theme.border) Text(ctx.markdown) .font(.system(size: 17 * themeEngine.layout.uiScale)) .lineSpacing(8) .foregroundStyle(theme.textPrimary) .textSelection(.enabled) } else if let loadError { Label(loadError, systemImage: "doc.plaintext") .font(ZyquoFont.body()).foregroundStyle(theme.textSecondary) } else { ProgressView().frame(maxWidth: .infinity).padding(ZyquoSpacing.xxl) } } .frame(maxWidth: 720, alignment: .leading) .frame(maxWidth: .infinity) .padding(.horizontal, ZyquoSpacing.xxl) .padding(.vertical, ZyquoSpacing.xl) } .background(theme.backgroundColor) .task(id: tab.id) { await load() } } private func summaryCard(_ ctx: PageContext) -> some View { VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { HStack { Label("AI summary", systemImage: "sparkles").font(ZyquoFont.caption) .foregroundStyle(theme.accentIndigo) Spacer() if !ai.isStreaming && ai.output.isEmpty { Button("Summarize", action: { summarize(ctx) }) .font(ZyquoFont.caption).foregroundStyle(theme.accent) } } if !ai.output.isEmpty { Text(ai.output).font(ZyquoFont.body()).foregroundStyle(theme.textPrimary).textSelection(.enabled) } else if ai.isStreaming { Text("Summarizing…").font(ZyquoFont.caption).foregroundStyle(theme.textSecondary) } } .padding(ZyquoSpacing.md) .background(RoundedRectangle(cornerRadius: ZyquoRadius.medium).fill(theme.accentSubtle)) } private func load() async { do { context = try await tab.extractPageContext() } catch { loadError = "Reader couldn't extract this page. It may be an app page or blocked." } } private func summarize(_ ctx: PageContext) { guard let model = env.model(for: .standard), let key = try? env.vault.apiKey(for: model.provider) else { return } ai.run(.summarize, on: ctx, model: model, apiKey: key) } }