// 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 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 } // 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"] static let f1 = Interest(id: "f1", label: "F1") // matching handled by isFormula1 /// Pinned filter pills, in order. static let pinned: [Interest] = [ f1, Interest(id: "uganda", label: "Uganda", keywords: ugandaKeywords), Interest(id: "south-africa", label: "South Africa", keywords: southAfricaKeywords), Interest(id: "finance", label: "Finance", topicSlug: "finance"), Interest(id: "tech", label: "Tech", topicSlug: "tech"), ] }