Replace the keyword/source-majority Interest system with a StoryPill enum that matches stories by canonical category slugs. StorySummary gains a tolerant tags[] (optional, decode-safe); effectiveTags uses tags, falling back to topic during the backend transition. No more headline keyword-searching on the client — the backend decides classification; the client asks slug membership. Pills: All, F1, Sport, AI, Cloud, HomeLab, Tech, Uganda, South Africa, Canada, US. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
228 lines
7.8 KiB
Swift
228 lines
7.8 KiB
Swift
// 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: - 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 ai = "AI"
|
||
case cloud = "Cloud"
|
||
case homelab = "HomeLab"
|
||
case tech = "Tech"
|
||
case uganda = "Uganda"
|
||
case southAfrica = "South Africa"
|
||
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 .ai: return ["artificial-intelligence", "machine-learning", "robotics"]
|
||
case .cloud: return ["cloud-computing"]
|
||
case .homelab: return ["homelab"]
|
||
case .tech: return ["technology", "programming-and-software-development",
|
||
"cybersecurity", "security", "privacy-and-data-protection",
|
||
"web-design-and-ui-ux", "wordpress-and-web-development"]
|
||
case .uganda: return ["uganda"]
|
||
case .southAfrica: return ["south-africa"]
|
||
case .canada: return ["canada"]
|
||
case .unitedStates: return ["united-states"]
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|