// 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 "finance": return "Finance" case "tech": return "Tech" case "politics": return "Politics" case "africa": return "Africa" default: return slug.prefix(1).uppercased() + slug.dropFirst() } } /// 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: - 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: - Pinned interest filters (client-side) // // The backend only classifies topics as finance/tech/politics/africa, so these // personal filters match keywords in the headline, summary, and source names of // the loaded stories instead of calling the API. struct Interest: Identifiable, Hashable { let id: String let label: String var keywords: [String] = [] /// If any of these appear, the story is NOT this interest — used to keep the /// region pills exclusive (East Africa excludes Uganda & South Africa). var excludeKeywords: [String] = [] /// When set, matches the backend topic classification instead of keywords /// (e.g. finance, tech). var topicSlug: String? = nil func matches(_ s: StorySummary) -> Bool { if id == "f1" { return Interest.isFormula1(s) } if id == "sport" { return Interest.isSports(s) } if let topicSlug { return s.topic.caseInsensitiveCompare(topicSlug) == .orderedSame } // Headline only — the backend's summaries/outlet names are polluted by // over-clustering (a Congo headline can carry a South-Africa summary or an // SA outlet), so they're unsafe to match on. let hay = s.headline.lowercased() if excludeKeywords.contains(where: { hay.contains($0) }) { return false } return keywords.contains { hay.contains($0) } } /// F1 = a clear F1 headline, or a majority of F1-feed sources. A single F1 /// source isn't enough — the over-clustered backend sometimes attaches one to /// an unrelated story. Avoids substring traps like "fia" matching "deFIAnt". static func isFormula1(_ s: StorySummary) -> Bool { let h = s.headline.lowercased() if h.contains("formula 1") || h.contains("formula one") || h.contains("grand prix") || h.hasPrefix("f1 ") || h.hasSuffix(" f1") || h.contains(" f1 ") || h.contains("f1:") { return true } let feeds = ["f1", "racefans", "motorsport", "autosport", "planetf1", "the race", "formula 1"] let srcs = s.sources guard !srcs.isEmpty else { return false } let n = srcs.filter { src in feeds.contains { src.name.lowercased().contains($0) } }.count return Double(n) / Double(srcs.count) >= 0.5 } /// Sport (non-F1) = a clear sports headline, or a majority of sports-outlet /// sources. The backend mis-tags World Cup / soccer into finance & tech, so /// this pulls them out. F1 has its own pill and is excluded here. static func isSports(_ s: StorySummary) -> Bool { if isFormula1(s) { return false } let h = s.headline.lowercased() let terms = ["world cup", "fifa", "premier league", "la liga", "champions league", "uefa", "ballon d'or", "knockout stage", "quarterfinal", "semifinal", "nba ", "nfl ", "nhl ", "mlb ", "super bowl", "playoff", "wimbledon", "grand slam", "olympic", "test match", "six nations", "messi", "ronaldo", "striker", "midfielder", "goalkeeper", "world cup match"] if terms.contains(where: { h.contains($0) }) { return true } let feeds = ["the athletic", "fox sports", "sportsnet", "espn", "sky sports", "bbc sport", "goal", "bleacher report", "sports illustrated", "sporting news"] let srcs = s.sources guard !srcs.isEmpty else { return false } let n = srcs.filter { src in feeds.contains { src.name.lowercased().contains($0) } }.count return Double(n) / Double(srcs.count) >= 0.5 } // Region keywords are place/person names found in the story text — never // outlet names (which are unreliable due to backend over-clustering). private static let ugandaKeywords = ["uganda", "ugandan", "kampala", "museveni", "entebbe", "jinja", "gulu"] private static let southAfricaKeywords = ["south africa", "south african", "johannesburg", "cape town", "pretoria", "durban", "ramaphosa", "eskom", "gauteng", "western cape", "soweto", "drakensberg", "mbalula", "zuma"] private static let canadaKeywords = ["canada", "canadian", "toronto", "ottawa", "vancouver", "montreal", "quebec", "alberta", "ontario", "manitoba", "calgary", "winnipeg", "trudeau", "carney"] private static let usKeywords = ["united states", "u.s.", "americans", "trump", "white house", "washington", "congress", "pentagon", "biden", "kamala", "harris", "wall street", "new york", "california", "texas", "florida", "supreme court", "senate", "capitol"] // Content topics — strong, unambiguous terms (no bare "ai"/"aws" substring traps). private static let aiKeywords = ["artificial intelligence", "machine learning", "deep learning", "generative ai", "chatgpt", "openai", "anthropic", "claude", "llm", "gpt", "gemini", "hugging face", "nvidia", "copilot", "agentic", "midjourney", "a.i."] private static let cloudKeywords = ["cloud", "azure", "google cloud", "kubernetes", "docker", "serverless", "cloudflare", "data center", "datacenter", "devops", "openstack", "terraform"] private static let homelabKeywords = ["homelab", "home lab", "self-host", "self-hosted", "selfhosted", "proxmox", "raspberry pi", "truenas", "unraid", "synology", "jellyfin", "home server", "nas ", "home assistant", "docker compose", "tailscale", "k3s", "mini pc"] static let f1 = Interest(id: "f1", label: "F1") // matching via isFormula1 static let sport = Interest(id: "sport", label: "Sport") // matching via isSports /// Pinned filter pills, in order. static let pinned: [Interest] = [ f1, sport, Interest(id: "uganda", label: "Uganda", keywords: ugandaKeywords), Interest(id: "south-africa", label: "South Africa", keywords: southAfricaKeywords), Interest(id: "canada", label: "Canada", keywords: canadaKeywords), Interest(id: "us", label: "US", keywords: usKeywords), Interest(id: "ai", label: "AI", keywords: aiKeywords), Interest(id: "cloud", label: "Cloud", keywords: cloudKeywords), Interest(id: "homelab", label: "HomeLab", keywords: homelabKeywords), Interest(id: "tech", label: "Tech", topicSlug: "tech"), ] }