// Theme.swift // Jarvis — shared design tokens, signal-score fade math, formatting, nav routes. // Every hex value here is from the spec; nothing is invented. import SwiftUI // MARK: - Hex colors extension Color { init(hex: String) { let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) var v: UInt64 = 0 Scanner(string: s).scanHexInt64(&v) let r, g, b: Double switch s.count { case 6: r = Double((v >> 16) & 0xFF) / 255 g = Double((v >> 8) & 0xFF) / 255 b = Double(v & 0xFF) / 255 default: r = 0; g = 0; b = 0 } self = Color(.sRGB, red: r, green: g, blue: b, opacity: 1) } } // MARK: - Palette enum Palette { static let orange = Color("KisaniOrange") // #FF5C00 static let black = Color.black // #000000 static let surface = Color(hex: "111111") static let surface2 = Color(hex: "1A1A1A") static let hairline = Color(hex: "1A1A1A") // health static let healthActive = Color(hex: "2A5A2A") static let healthFailing = Color(hex: "AA6600") static let healthDead = Color(hex: "5A1A1A") // blocks static let consensusBorder = Color(hex: "FF5C00") static let consensusFill = Color(hex: "1F1206") // dark orange static let conflictBorder = Color(hex: "5A1A1A") // dark red static let conflictFill = Color(hex: "1A0C0C") // dark red static let cachedGreen = Color(hex: "2A5A2A") static let bodyText = Color(hex: "4A4A4A") } // MARK: - Signal-score fade // // Spec anchors: // stripe : 97 → #FF5C00, fades to #1A1A1A at 0 // title : 97 → white, 20 → #444444, 0–19 near invisible // score : 80–100 full orange · 50–79 dimmed orange · 20–49 dark brown · 0–19 near invisible enum Signal { private static func lerp(_ a: (Double, Double, Double), _ b: (Double, Double, Double), _ t: Double) -> Color { let t = max(0, min(1, t)) return Color(.sRGB, red: a.0 + (b.0 - a.0) * t, green: a.1 + (b.1 - a.1) * t, blue: a.2 + (b.2 - a.2) * t, opacity: 1) } /// 3pt left stripe color: #1A1A1A (0) → #FF5C00 (97+). static func stripeColor(_ score: Int) -> Color { let t = Double(score) / 97.0 return lerp((0x1A/255, 0x1A/255, 0x1A/255), // #1A1A1A (0xFF/255, 0x5C/255, 0x00/255), // #FF5C00 t) } /// Story title color: white (97+) → #444444 (20) → near invisible (0). static func titleColor(_ score: Int) -> Color { if score >= 97 { return .white } if score >= 20 { let t = Double(score - 20) / Double(97 - 20) return lerp((0x44/255, 0x44/255, 0x44/255), // #444444 (1, 1, 1), // white t) } // 0–19 near invisible: #1C1C1C → #444444 let t = Double(score) / 20.0 return lerp((0x1C/255, 0x1C/255, 0x1C/255), (0x44/255, 0x44/255, 0x44/255), t) } /// Signal score number color: stays in orange/brown hue, fades with score. /// near-invisible brown (0) → full #FF5C00 (100). static func scoreColor(_ score: Int) -> Color { let t = Double(score) / 100.0 return lerp((0x2A/255, 0x18/255, 0x10/255), // near-invisible brown (0xFF/255, 0x5C/255, 0x00/255), // #FF5C00 t) } } // MARK: - Topic display enum Topic { static func label(_ slug: String) -> String { switch slug.lowercased() { case "ai", "artificial-intelligence": return "AI" case "us", "united-states": return "US" case "formula-1": return "Formula 1" case "ui-ux", "web-design-and-ui-ux": return "Web Design" default: // Slug → Title Case, e.g. "cloud-computing" → "Cloud Computing". return slug.split(separator: "-") .map { $0.prefix(1).uppercased() + $0.dropFirst() } .joined(separator: " ") } } /// Topic pills shown on the home screen. static let filters: [(label: String, slug: String?)] = [ ("All", nil), ("Finance", "finance"), ("Tech", "tech"), ("Politics", "politics"), ("Africa", "africa"), ] } // MARK: - Time formatting extension Date { /// Short relative string e.g. "just now", "3 min", "2 hr", "4 d". func timeAgoShort(reference: Date = Date()) -> String { let secs = max(0, Int(reference.timeIntervalSince(self))) switch secs { case 0..<60: return "just now" case 60..<3600: return "\(secs / 60) min" case 3600..<86400: return "\(secs / 3600) hr" default: return "\(secs / 86400) d" } } /// "08:03" style monospaced clock for timeline / source chips. var clockShort: String { let f = DateFormatter() f.dateFormat = "HH:mm" return f.string(from: self) } } func syncedMinutesAgo(_ date: Date?, reference: Date = Date()) -> String { guard let date else { return "never" } return date.timeAgoShort(reference: reference) } // MARK: - Navigation routes /// Route into the article reader. Carries the parent story context so the /// back button can show the truncated headline. struct ArticleRoute: Hashable { let articleId: String let storyId: String let parentHeadline: String } // MARK: - Appearance mode enum AppearanceMode: String, CaseIterable { case system, light, dark var colorScheme: ColorScheme? { switch self { case .system: return nil case .light: return .light case .dark: return .dark } } var label: String { switch self { case .system: return "System" case .light: return "Light" case .dark: return "Dark" } } var icon: String { switch self { case .system: return "circle.lefthalf.filled" case .light: return "sun.max" case .dark: return "moon.stars" } } } // MARK: - Stable feed ordering extension StorySummary { /// Deterministic ranking: signal score descending, then id descending — the /// same order the backend uses (`signal_score DESC, id DESC`). Without the /// id tiebreaker, equal-score stories reshuffle on every (unstable) re-sort. static func feedOrder(_ a: StorySummary, _ b: StorySummary) -> Bool { a.signalScore != b.signalScore ? a.signalScore > b.signalScore : a.id > b.id } } // MARK: - Story pills (backend-tag driven) // // The backend classifies each story into canonical category slugs in `tags[]`. // The client filters by pill -> slug membership only; no headline keyword // matching. `topic` is a fallback while the backend finishes emitting `tags[]`. enum StoryPill: String, CaseIterable, Identifiable { case all = "All" case f1 = "F1" case sport = "Sport" case tech = "Tech" // covers AI, Cloud, HomeLab — use sub-sections inside case uganda = "Uganda" case southAfrica = "South Africa" case canada = "Canada" case unitedStates = "US" var id: String { rawValue } var label: String { rawValue } var slugs: Set { switch self { case .all: return [] case .f1: return ["formula-1"] case .sport: return ["sports", "esports"] case .tech: return [ "technology", "programming-and-software-development", "cybersecurity", "security", "privacy-and-data-protection", "web-design-and-ui-ux", "wordpress-and-web-development", "artificial-intelligence", "machine-learning", "robotics", "cloud-computing", "homelab", ] case .uganda: return ["uganda"] case .southAfrica: return ["south-africa"] case .canada: return ["canada"] case .unitedStates: return ["united-states"] } } /// Sub-sections to show inside the card layout for this pill (nil = flat). var subSections: [NewsSection]? { switch self { case .tech: return NewsSection.techSubSections default: return nil } } } extension StorySummary { /// Backend `tags[]` are the source of truth; fall back to `topic` during the /// transition while the backend finishes emitting tags. var effectiveTags: Set { let clean = Set((tags ?? []).filter { !$0.isEmpty }) return clean.isEmpty ? [topic] : clean } func matches(_ pill: StoryPill) -> Bool { pill == .all || !effectiveTags.isDisjoint(with: pill.slugs) } func inSection(_ section: NewsSection) -> Bool { !effectiveTags.isDisjoint(with: section.slugs) } } // MARK: - Wordmark struct JarvisWordmark: View { var size: CGFloat = 30 private var font: Font { .custom("Courier New", size: size).bold() } var body: some View { HStack(spacing: 0) { Text("j").font(font).foregroundStyle(Palette.orange) Text("arvis").font(font).foregroundStyle(.white) } } } // MARK: - News sections (the "All" front page) // // Groups the firehose into Apple/Google-News-style sections. A story lands in // the first section (by this order) whose slugs it carries; `pill` powers "See all". struct NewsSection: Identifiable { let id: String let label: String let slugs: Set let pill: StoryPill? static let sections: [NewsSection] = [ .init(id: "us", label: "US", slugs: ["united-states"], pill: .unitedStates), .init(id: "world", label: "World", slugs: ["world-news", "news", "news-and-current-affairs"], pill: nil), .init(id: "politics", label: "Politics", slugs: ["politics", "human-rights", "investigative"], pill: nil), .init(id: "business", label: "Business", slugs: ["business", "finance", "fintech", "cryptocurrency", "blockchain", "entrepreneur", "startups", "real-estate"], pill: nil), .init(id: "tech", label: "Tech", slugs: ["technology", "programming-and-software-development", "cybersecurity", "security", "web-design-and-ui-ux", "wordpress-and-web-development", "privacy-and-data-protection", "cloud-computing", "artificial-intelligence", "machine-learning", "robotics", "homelab"], pill: .tech), .init(id: "africa", label: "Africa", slugs: ["africa", "east-africa", "south-africa", "uganda"], pill: nil), .init(id: "sport", label: "Sport", slugs: ["sports", "esports", "formula-1"], pill: .sport), .init(id: "science", label: "Science", slugs: ["science", "astronomy", "environment", "green-technology", "sustainability"], pill: nil), .init(id: "entertainment", label: "Entertainment",slugs: ["entertainment", "music", "pop-culture", "video-game", "arts-and-culture", "books-and-literature"], pill: nil), .init(id: "health", label: "Health", slugs: ["health", "health-and-wellness", "mental-health"], pill: nil), .init(id: "lifestyle", label: "Lifestyle", slugs: ["lifestyle", "food-and-drink", "travel", "gardening", "outdoor-and-hiking", "parenting-and-family", "interior-design-and-home-decor", "photography", "self-improvement-and-personal-development", "education", "e-learning"], pill: nil), ] /// Sub-sections shown inside the Tech pill view. static let techSubSections: [NewsSection] = [ .init(id: "t-ai", label: "AI", slugs: ["artificial-intelligence", "machine-learning", "robotics"], pill: nil), .init(id: "t-cloud", label: "Cloud", slugs: ["cloud-computing"], pill: nil), .init(id: "t-homelab", label: "HomeLab", slugs: ["homelab"], pill: nil), .init(id: "t-security", label: "Security", slugs: ["cybersecurity", "security", "privacy-and-data-protection"], pill: nil), .init(id: "t-dev", label: "Dev", slugs: ["programming-and-software-development", "web-design-and-ui-ux", "wordpress-and-web-development"], pill: nil), .init(id: "t-tech", label: "Technology", slugs: ["technology"], pill: nil), ] }