Files
jarvis/Jarvis/Views/Shared/Theme.swift
Kutesir 326d5fe983
Some checks failed
CI / Build · Debug (push) Has been cancelled
CI / Build · Release (push) Has been cancelled
fix: stale-news control — repair clientRank's age source, add isFossil backstop
clientRank already existed specifically to stop stale high-scorers from
leading the feed, but computed age from updatedAt — the same timestamp
that keeps resetting whenever the backend attaches any article to a
cluster (BACKLOG #15). A fossil's age always read as ~0, silently
defeating the whole mechanism. Fixed to use firstSeenAt.

clientRank's decay is soft (max 20% penalty) and isn't always enough
against a high signalScore alone, so added StorySummary.isFossil (>5
days old by firstSeenAt) as a hard backstop: mainStories and
makeDigest now sink fossils below current stories, so one can never
take the hero/lead slot regardless of score. Still shown in the feed,
just not in the most prominent position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 02:56:34 +03:00

442 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
import UIKit
// 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)
}
}
extension UIColor {
convenience init(hex: String) {
let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
var v: UInt64 = 0
Scanner(string: s).scanHexInt64(&v)
let r, g, b: CGFloat
switch s.count {
case 6:
r = CGFloat((v >> 16) & 0xFF) / 255
g = CGFloat((v >> 8) & 0xFF) / 255
b = CGFloat(v & 0xFF) / 255
default:
r = 0; g = 0; b = 0
}
self.init(red: r, green: g, blue: b, alpha: 1)
}
}
// MARK: - Palette
enum Palette {
static let orange = Color("KisaniOrange") // #FF5C00
static func adaptive(dark: String, light: String) -> Color {
Color(UIColor { traits in
traits.userInterfaceStyle == .dark ? UIColor(hex: dark) : UIColor(hex: light)
})
}
static let background = adaptive(dark: "0A0A0A", light: "F7F4EF")
static let black = background
static let surface = adaptive(dark: "111111", light: "EFE9DD")
static let surface2 = adaptive(dark: "1A1A1A", light: "E3DBCE")
static let hairline = adaptive(dark: "1A1A1A", light: "D6CEC0")
static let primaryText = adaptive(dark: "F5F2ED", light: "171411")
static let secondaryText = adaptive(dark: "9A9A9A", light: "4F4941")
static let tertiaryText = adaptive(dark: "666666", light: "6F675E")
static let mutedText = adaptive(dark: "4A4A4A", light: "948B80")
static let chipFill = adaptive(dark: "242424", light: "DDD3C1")
static let chipText = adaptive(dark: "E0E0E0", light: "28231E")
static let chipMutedText = adaptive(dark: "787878", light: "68625A")
// health
static let healthActive = adaptive(dark: "2A5A2A", light: "DCEBDD")
static let healthFailing = adaptive(dark: "AA6600", light: "F2D6A6")
static let healthDead = adaptive(dark: "5A1A1A", light: "E9C7C7")
// blocks
static let consensusBorder = Color(hex: "FF5C00")
static let consensusFill = adaptive(dark: "1F1206", light: "FFE2CA")
static let conflictBorder = adaptive(dark: "5A1A1A", light: "BF5B5B")
static let conflictFill = adaptive(dark: "1A0C0C", light: "F3D8D8")
static let cachedGreen = adaptive(dark: "2A5A2A", light: "2C6A38")
static let bodyText = adaptive(dark: "8A8A8A", light: "4A453F")
}
// MARK: - Signal-score fade
//
// Spec anchors:
// stripe : 97 #FF5C00, fades to #1A1A1A at 0
// title : 97 white, 20 #444444, 019 near invisible
// score : 80100 full orange · 5079 dimmed orange · 2049 dark brown · 019 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)
}
private static func lerpTuple(_ a: (Double, Double, Double),
_ b: (Double, Double, Double),
_ t: Double) -> (Double, Double, Double) {
let t = max(0, min(1, t))
return (
a.0 + (b.0 - a.0) * t,
a.1 + (b.1 - a.1) * t,
a.2 + (b.2 - a.2) * t
)
}
private static func adaptiveLerp(_ darkA: (Double, Double, Double),
_ darkB: (Double, Double, Double),
_ lightA: (Double, Double, Double),
_ lightB: (Double, Double, Double),
_ t: Double) -> Color {
Color(UIColor { traits in
let rgb = traits.userInterfaceStyle == .dark
? lerpTuple(darkA, darkB, t)
: lerpTuple(lightA, lightB, t)
return UIColor(red: rgb.0, green: rgb.1, blue: rgb.2, alpha: 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 Palette.primaryText }
if score >= 20 {
let t = Double(score - 20) / Double(97 - 20)
return adaptiveLerp(
(0x44/255, 0x44/255, 0x44/255), (0xF5/255, 0xF2/255, 0xED/255),
(0x8A/255, 0x81/255, 0x76/255), (0x17/255, 0x14/255, 0x11/255),
t
)
}
let t = Double(score) / 20.0
return adaptiveLerp(
(0x1C/255, 0x1C/255, 0x1C/255), (0x44/255, 0x44/255, 0x44/255),
(0xD6/255, 0xCE/255, 0xC0/255), (0x8A/255, 0x81/255, 0x76/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: - Freshness-weighted feed ordering
//
// The server already applies freshness when computing signalScore, but that
// snapshot ages once it's cached on the client. clientRank applies a lightweight
// time-decay so fresh stories naturally surface over stale high-scorers.
//
// Formula: rank = score × (0.80 + 0.20 × decay)
// decay = 1 / (1 + ageHours / 24) 1.0 now · 0.50 @ 24 h · 0.33 @ 48 h
//
// Effect: a score-80 story from 1 hour ago (rank 79.8) beats a score-82 story
// from 48 hours ago (rank 71.4). Pure score still dominates for same-age stories.
//
// Age is measured from `firstSeenAt`, not `updatedAt` the backend bumps
// `updatedAt` every time it attaches another article to a cluster, however
// loosely related (see BACKLOG #15), so a story can read as "just now" while
// being weeks old by `firstSeenAt`. Using `updatedAt` here previously
// neutralized the whole point of this decay: a fossil story's age always
// computed near zero.
extension StorySummary {
var clientRank: Double {
let ageHours = max(0.0, -(firstSeenAt ?? updatedAt).timeIntervalSinceNow) / 3600.0
let decay = 1.0 / (1.0 + ageHours / 24.0)
return Double(signalScore) * (0.80 + 0.20 * decay)
}
/// Even with the fix above, this decay is soft (max 20% penalty) a
/// high enough signalScore can still outrank fresher, lower-scored
/// stories. `isFossil` is a hard backstop views use to keep genuinely
/// old stories out of the lead/hero slot regardless of score.
static let fossilThresholdDays: Double = 5
var isFossil: Bool {
let ageSeconds = -(firstSeenAt ?? updatedAt).timeIntervalSinceNow
return ageSeconds > StorySummary.fossilThresholdDays * 86400
}
static func feedOrder(_ a: StorySummary, _ b: StorySummary) -> Bool {
let diff = a.clientRank - b.clientRank
// Stable tiebreak for nearly-equal ranks: newer ID wins (IDs are
// lexicographically monotone from the backend).
if abs(diff) < 0.5 { return a.id > b.id }
return diff > 0
}
}
// 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" // AI, Cloud, HomeLab sub-sections inside
case eastAfrica = "East Africa" // Uganda + broader East Africa, Uganda pinned first
case southernAfrica = "Southern Africa" // SA, Namibia, Botswana pinned first
case canada = "Canada"
case unitedStates = "US"
var id: String { rawValue }
var label: String { rawValue }
var slugs: Set<String> {
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 .eastAfrica: return ["east-africa", "uganda"]
case .southernAfrica: return [
"south-africa", "namibia", "botswana",
"zimbabwe", "zambia", "mozambique", "southern-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
case .eastAfrica: return NewsSection.eastAfricaSubSections
case .southernAfrica: return NewsSection.southernAfricaSubSections
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<String> {
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("ervis").font(font).foregroundStyle(Palette.primaryText)
}
}
}
// 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<String>
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: "east-africa", label: "East Africa", slugs: ["east-africa", "uganda"], pill: .eastAfrica),
.init(id: "southern-africa",label: "Southern Africa", slugs: ["south-africa", "namibia", "botswana", "zimbabwe", "zambia", "mozambique", "southern-africa"], pill: .southernAfrica),
.init(id: "africa", label: "Africa", slugs: ["africa"], 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),
]
/// Sub-sections shown inside the East Africa pill view Uganda pinned first.
static let eastAfricaSubSections: [NewsSection] = [
.init(id: "ea-uganda", label: "Uganda", slugs: ["uganda"], pill: nil),
.init(id: "ea-kenya", label: "Kenya", slugs: ["east-africa"], pill: nil),
]
/// Sub-sections shown inside the Southern Africa pill SA, Namibia, Botswana first.
static let southernAfricaSubSections: [NewsSection] = [
.init(id: "sa-za", label: "South Africa", slugs: ["south-africa"], pill: nil),
.init(id: "sa-na", label: "Namibia", slugs: ["namibia"], pill: nil),
.init(id: "sa-bw", label: "Botswana", slugs: ["botswana"], pill: nil),
.init(id: "sa-zw", label: "Zimbabwe", slugs: ["zimbabwe"], pill: nil),
.init(id: "sa-zm", label: "Zambia", slugs: ["zambia"], pill: nil),
.init(id: "sa-mz", label: "Mozambique", slugs: ["mozambique"], pill: nil),
]
}