feat: East Africa + Southern Africa pills with regional sub-sections
Some checks failed
CI / Build · macos-14 · Debug (push) Has been cancelled
CI / Build · macos-15 · Debug (push) Has been cancelled
CI / Build · macos-14 · Release (push) Has been cancelled
CI / Build · macos-15 · Release (push) Has been cancelled

- East Africa pill: merges Uganda + east-africa tags; Uganda pinned first via sub-sections
- Southern Africa pill: covers SA, Namibia, Botswana, Zimbabwe, Zambia, Mozambique; SA/Namibia/Botswana prioritized
- StoryStore.setPill now resets lastSyncedAt to bypass 30s rate-limit on filter switch
- sectionSupplement background fetch populates sparse regional sections in All digest
- SignalFeedView digestContent: flat hero+scroll path for pills without subSections (was showing max 5)
- Theme: removed standalone Uganda pill, wired new sub-section layouts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-22 02:33:33 +03:00
parent 6677033faa
commit 595f21fb6c
27 changed files with 635 additions and 293 deletions

View File

@@ -39,5 +39,5 @@ struct CachedBadge: View {
CachedBadge(text: "All 7 articles cached · available offline")
}
.padding()
.background(Color.black)
.background(Palette.background)
}

View File

@@ -23,7 +23,7 @@ extension ConnectionState {
case .connected: return Color(hex: "33C25E")
case .connecting,
.reconnecting: return Palette.healthFailing
case .disconnected: return Color(hex: "555555")
case .disconnected: return Palette.tertiaryText
}
}
}
@@ -56,5 +56,5 @@ struct ConnectionBanner: View {
ConnectionBanner(state: .disconnected)
}
.padding()
.background(Color.black)
.background(Palette.background)
}

View File

@@ -29,5 +29,5 @@ struct SignalStripe: View {
}
}
.padding()
.background(Color.black)
.background(Palette.background)
}

View File

@@ -26,11 +26,11 @@ struct SourceChips: View {
private func chip(_ text: String, muted: Bool = false) -> some View {
Text(text)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(muted ? Color(hex: "777777") : Color(hex: "CCCCCC"))
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(Palette.surface2)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(muted ? Palette.chipMutedText : Palette.chipText)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Palette.chipFill)
.clipShape(Capsule())
}
}
@@ -38,5 +38,5 @@ struct SourceChips: View {
#Preview {
SourceChips(names: ["Daily Monitor", "NilePost", "The EastAfrican"], total: 7)
.padding()
.background(Color.black)
.background(Palette.background)
}

View File

@@ -3,6 +3,7 @@
// Every hex value here is from the spec; nothing is invented.
import SwiftUI
import UIKit
// MARK: - Hex colors
@@ -24,28 +25,63 @@ extension Color {
}
}
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 let black = Color.black // #000000
static let surface = Color(hex: "111111")
static let surface2 = Color(hex: "1A1A1A")
static let hairline = Color(hex: "1A1A1A")
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 = Color(hex: "2A5A2A")
static let healthFailing = Color(hex: "AA6600")
static let healthDead = Color(hex: "5A1A1A")
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 = Color(hex: "1F1206") // dark orange
static let conflictBorder = Color(hex: "5A1A1A") // dark red
static let conflictFill = Color(hex: "1A0C0C") // dark red
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 = Color(hex: "2A5A2A")
static let bodyText = Color(hex: "4A4A4A")
static let cachedGreen = adaptive(dark: "2A5A2A", light: "2C6A38")
static let bodyText = adaptive(dark: "8A8A8A", light: "4A453F")
}
// MARK: - Signal-score fade
@@ -67,6 +103,30 @@ enum Signal {
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
@@ -77,18 +137,21 @@ enum Signal {
/// Story title color: white (97+) #444444 (20) near invisible (0).
static func titleColor(_ score: Int) -> Color {
if score >= 97 { return .white }
if score >= 97 { return Palette.primaryText }
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)
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
)
}
// 019 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)
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.
@@ -195,14 +258,31 @@ enum AppearanceMode: String, CaseIterable {
}
}
// MARK: - Stable feed ordering
// 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.
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.
var clientRank: Double {
let ageHours = max(0.0, -updatedAt.timeIntervalSinceNow) / 3600.0
let decay = 1.0 / (1.0 + ageHours / 24.0)
return Double(signalScore) * (0.80 + 0.20 * decay)
}
static func feedOrder(_ a: StorySummary, _ b: StorySummary) -> Bool {
a.signalScore != b.signalScore ? a.signalScore > b.signalScore : a.id > b.id
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
}
}
@@ -216,9 +296,9 @@ enum StoryPill: String, CaseIterable, Identifiable {
case all = "All"
case f1 = "F1"
case sport = "Sport"
case tech = "Tech" // covers AI, Cloud, HomeLab use sub-sections inside
case uganda = "Uganda"
case southAfrica = "South Africa"
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"
@@ -227,28 +307,33 @@ enum StoryPill: String, CaseIterable, Identifiable {
var slugs: Set<String> {
switch self {
case .all: return []
case .f1: return ["formula-1"]
case .sport: return ["sports", "esports"]
case .tech: return [
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 .uganda: return ["uganda"]
case .southAfrica: return ["south-africa"]
case .canada: return ["canada"]
case .unitedStates: return ["united-states"]
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
default: return nil
case .tech: return NewsSection.techSubSections
case .eastAfrica: return NewsSection.eastAfricaSubSections
case .southernAfrica: return NewsSection.southernAfricaSubSections
default: return nil
}
}
}
@@ -278,7 +363,7 @@ struct JarvisWordmark: View {
var body: some View {
HStack(spacing: 0) {
Text("j").font(font).foregroundStyle(Palette.orange)
Text("arvis").font(font).foregroundStyle(.white)
Text("arvis").font(font).foregroundStyle(Palette.primaryText)
}
}
}
@@ -300,7 +385,9 @@ struct NewsSection: Identifiable {
.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: "africa", label: "Africa", slugs: ["africa", "east-africa", "south-africa", "uganda"], pill: nil),
.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),
@@ -317,4 +404,20 @@ struct NewsSection: Identifiable {
.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),
]
}