2026-06-18 18:04:59 +03:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
2026-06-18 23:33:22 +03:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-19 01:00:39 +03:00
|
|
|
|
|
|
|
|
|
|
// 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 {
|
2026-06-19 14:17:32 +03:00
|
|
|
|
if id == "f1" { return Interest.isFormula1(s) }
|
2026-06-19 17:39:45 +03:00
|
|
|
|
if id == "sport" { return Interest.isSports(s) }
|
2026-06-19 01:00:39 +03:00
|
|
|
|
if let topicSlug {
|
|
|
|
|
|
return s.topic.caseInsensitiveCompare(topicSlug) == .orderedSame
|
|
|
|
|
|
}
|
2026-06-19 14:17:32 +03:00
|
|
|
|
// 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()
|
2026-06-19 01:00:39 +03:00
|
|
|
|
if excludeKeywords.contains(where: { hay.contains($0) }) { return false }
|
|
|
|
|
|
return keywords.contains { hay.contains($0) }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 14:17:32 +03:00
|
|
|
|
/// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 17:39:45 +03:00
|
|
|
|
/// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 01:00:39 +03:00
|
|
|
|
// 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"]
|
2026-06-19 17:39:45 +03:00
|
|
|
|
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"]
|
2026-06-19 01:00:39 +03:00
|
|
|
|
|
2026-06-19 17:39:45 +03:00
|
|
|
|
static let f1 = Interest(id: "f1", label: "F1") // matching via isFormula1
|
|
|
|
|
|
static let sport = Interest(id: "sport", label: "Sport") // matching via isSports
|
2026-06-19 01:00:39 +03:00
|
|
|
|
|
2026-06-19 14:17:32 +03:00
|
|
|
|
/// Pinned filter pills, in order.
|
2026-06-19 01:00:39 +03:00
|
|
|
|
static let pinned: [Interest] = [
|
|
|
|
|
|
f1,
|
2026-06-19 17:39:45 +03:00
|
|
|
|
sport,
|
2026-06-19 01:00:39 +03:00
|
|
|
|
Interest(id: "uganda", label: "Uganda", keywords: ugandaKeywords),
|
|
|
|
|
|
Interest(id: "south-africa", label: "South Africa", keywords: southAfricaKeywords),
|
2026-06-19 17:39:45 +03:00
|
|
|
|
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),
|
2026-06-19 01:00:39 +03:00
|
|
|
|
Interest(id: "tech", label: "Tech", topicSlug: "tech"),
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|