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
|
2026-06-22 02:33:33 +03:00
|
|
|
|
import UIKit
|
2026-06-18 18:04:59 +03:00
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 02:33:33 +03:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 18:04:59 +03:00
|
|
|
|
// MARK: - Palette
|
|
|
|
|
|
|
|
|
|
|
|
enum Palette {
|
2026-06-22 02:33:33 +03:00
|
|
|
|
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")
|
2026-06-18 18:04:59 +03:00
|
|
|
|
|
|
|
|
|
|
// health
|
2026-06-22 02:33:33 +03:00
|
|
|
|
static let healthActive = adaptive(dark: "2A5A2A", light: "DCEBDD")
|
|
|
|
|
|
static let healthFailing = adaptive(dark: "AA6600", light: "F2D6A6")
|
|
|
|
|
|
static let healthDead = adaptive(dark: "5A1A1A", light: "E9C7C7")
|
2026-06-18 18:04:59 +03:00
|
|
|
|
|
|
|
|
|
|
// blocks
|
|
|
|
|
|
static let consensusBorder = Color(hex: "FF5C00")
|
2026-06-22 02:33:33 +03:00
|
|
|
|
static let consensusFill = adaptive(dark: "1F1206", light: "FFE2CA")
|
|
|
|
|
|
static let conflictBorder = adaptive(dark: "5A1A1A", light: "BF5B5B")
|
|
|
|
|
|
static let conflictFill = adaptive(dark: "1A0C0C", light: "F3D8D8")
|
2026-06-18 18:04:59 +03:00
|
|
|
|
|
2026-06-22 02:33:33 +03:00
|
|
|
|
static let cachedGreen = adaptive(dark: "2A5A2A", light: "2C6A38")
|
|
|
|
|
|
static let bodyText = adaptive(dark: "8A8A8A", light: "4A453F")
|
2026-06-18 18:04:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 02:33:33 +03:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 18:04:59 +03:00
|
|
|
|
/// 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 {
|
2026-06-22 02:33:33 +03:00
|
|
|
|
if score >= 97 { return Palette.primaryText }
|
2026-06-18 18:04:59 +03:00
|
|
|
|
if score >= 20 {
|
|
|
|
|
|
let t = Double(score - 20) / Double(97 - 20)
|
2026-06-22 02:33:33 +03:00
|
|
|
|
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
|
|
|
|
|
|
)
|
2026-06-18 18:04:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
let t = Double(score) / 20.0
|
2026-06-22 02:33:33 +03:00
|
|
|
|
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
|
|
|
|
|
|
)
|
2026-06-18 18:04:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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() {
|
2026-06-20 18:42:09 +03:00
|
|
|
|
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: " ")
|
2026-06-18 18:04:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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
|
|
|
|
|
2026-06-21 01:30:36 +03:00
|
|
|
|
// 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"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 02:33:33 +03:00
|
|
|
|
// 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.
|
2026-07-13 02:56:34 +03:00
|
|
|
|
//
|
|
|
|
|
|
// 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.
|
2026-06-18 23:33:22 +03:00
|
|
|
|
|
|
|
|
|
|
extension StorySummary {
|
2026-06-22 02:33:33 +03:00
|
|
|
|
var clientRank: Double {
|
2026-07-13 02:56:34 +03:00
|
|
|
|
let ageHours = max(0.0, -(firstSeenAt ?? updatedAt).timeIntervalSinceNow) / 3600.0
|
2026-06-22 02:33:33 +03:00
|
|
|
|
let decay = 1.0 / (1.0 + ageHours / 24.0)
|
|
|
|
|
|
return Double(signalScore) * (0.80 + 0.20 * decay)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 02:56:34 +03:00
|
|
|
|
/// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 23:33:22 +03:00
|
|
|
|
static func feedOrder(_ a: StorySummary, _ b: StorySummary) -> Bool {
|
2026-06-22 02:33:33 +03:00
|
|
|
|
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
|
2026-06-18 23:33:22 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-19 01:00:39 +03:00
|
|
|
|
|
Filter pills by backend tags (slug membership), drop keyword matching
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>
2026-06-19 21:55:57 +03:00
|
|
|
|
// MARK: - Story pills (backend-tag driven)
|
2026-06-19 01:00:39 +03:00
|
|
|
|
//
|
Filter pills by backend tags (slug membership), drop keyword matching
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>
2026-06-19 21:55:57 +03:00
|
|
|
|
// 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"
|
2026-06-22 02:33:33 +03:00
|
|
|
|
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
|
Filter pills by backend tags (slug membership), drop keyword matching
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>
2026-06-19 21:55:57 +03:00
|
|
|
|
case canada = "Canada"
|
|
|
|
|
|
case unitedStates = "US"
|
|
|
|
|
|
|
|
|
|
|
|
var id: String { rawValue }
|
|
|
|
|
|
var label: String { rawValue }
|
|
|
|
|
|
|
|
|
|
|
|
var slugs: Set<String> {
|
|
|
|
|
|
switch self {
|
2026-06-22 02:33:33 +03:00
|
|
|
|
case .all: return []
|
|
|
|
|
|
case .f1: return ["formula-1"]
|
|
|
|
|
|
case .sport: return ["sports", "esports"]
|
|
|
|
|
|
case .tech: return [
|
2026-06-21 02:53:41 +03:00
|
|
|
|
"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",
|
|
|
|
|
|
]
|
2026-06-22 02:33:33 +03:00
|
|
|
|
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"]
|
2026-06-19 01:00:39 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-21 02:53:41 +03:00
|
|
|
|
|
|
|
|
|
|
/// Sub-sections to show inside the card layout for this pill (nil = flat).
|
|
|
|
|
|
var subSections: [NewsSection]? {
|
|
|
|
|
|
switch self {
|
2026-06-22 02:33:33 +03:00
|
|
|
|
case .tech: return NewsSection.techSubSections
|
|
|
|
|
|
case .eastAfrica: return NewsSection.eastAfricaSubSections
|
|
|
|
|
|
case .southernAfrica: return NewsSection.southernAfricaSubSections
|
|
|
|
|
|
default: return nil
|
2026-06-21 02:53:41 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
Filter pills by backend tags (slug membership), drop keyword matching
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>
2026-06-19 21:55:57 +03:00
|
|
|
|
}
|
2026-06-19 01:00:39 +03:00
|
|
|
|
|
Filter pills by backend tags (slug membership), drop keyword matching
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>
2026-06-19 21:55:57 +03:00
|
|
|
|
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
|
2026-06-19 14:17:32 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
Filter pills by backend tags (slug membership), drop keyword matching
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>
2026-06-19 21:55:57 +03:00
|
|
|
|
func matches(_ pill: StoryPill) -> Bool {
|
|
|
|
|
|
pill == .all || !effectiveTags.isDisjoint(with: pill.slugs)
|
2026-06-19 17:39:45 +03:00
|
|
|
|
}
|
Turn "All" into a sectioned news front page
Instead of one long firehose, "All" now renders Apple/Google-News-style:
a Top Stories lead block, then category sections (World, Politics, Business,
AI, Technology, Africa, Sport, Science, Entertainment, Health, Lifestyle) with
the top few stories each and a "See all" jump to the matching pill. Sections are
built from backend tags[]; specific pills keep the flat list. "All" preloads up
to ~100 stories to fill sections.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:34:14 +03:00
|
|
|
|
|
|
|
|
|
|
func inSection(_ section: NewsSection) -> Bool {
|
|
|
|
|
|
!effectiveTags.isDisjoint(with: section.slugs)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 02:15:23 +03:00
|
|
|
|
// 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)
|
2026-07-13 02:12:48 +03:00
|
|
|
|
Text("ervis").font(font).foregroundStyle(Palette.primaryText)
|
2026-06-21 02:15:23 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Turn "All" into a sectioned news front page
Instead of one long firehose, "All" now renders Apple/Google-News-style:
a Top Stories lead block, then category sections (World, Politics, Business,
AI, Technology, Africa, Sport, Science, Entertainment, Health, Lifestyle) with
the top few stories each and a "See all" jump to the matching pill. Sections are
built from backend tags[]; specific pills keep the flat list. "All" preloads up
to ~100 stories to fill sections.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:34:14 +03:00
|
|
|
|
// 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] = [
|
2026-06-21 02:53:41 +03:00
|
|
|
|
.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),
|
2026-06-22 02:33:33 +03:00
|
|
|
|
.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),
|
2026-06-21 02:53:41 +03:00
|
|
|
|
.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),
|
Turn "All" into a sectioned news front page
Instead of one long firehose, "All" now renders Apple/Google-News-style:
a Top Stories lead block, then category sections (World, Politics, Business,
AI, Technology, Africa, Sport, Science, Entertainment, Health, Lifestyle) with
the top few stories each and a "See all" jump to the matching pill. Sections are
built from backend tags[]; specific pills keep the flat list. "All" preloads up
to ~100 stories to fill sections.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:34:14 +03:00
|
|
|
|
]
|
2026-06-22 02:33:33 +03:00
|
|
|
|
|
|
|
|
|
|
/// 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),
|
|
|
|
|
|
]
|
2026-06-19 01:00:39 +03:00
|
|
|
|
}
|