diff --git a/Jarvis/Connectivity/ConnectivityManager.swift b/Jarvis/Connectivity/ConnectivityManager.swift
index f0ebf9a..f4ffb1b 100644
--- a/Jarvis/Connectivity/ConnectivityManager.swift
+++ b/Jarvis/Connectivity/ConnectivityManager.swift
@@ -106,6 +106,8 @@ final class ConnectivityManager: ObservableObject {
guard host != lastActivated else { return } // avoid thrashing the WS
lastActivated = host
await ServerSettings.shared.activate(host: host)
- await StoryStore.shared.loadStories(refresh: true)
+ // Full paginated load — page 1 only misses unread stories on pages 2–5
+ // that may have arrived since last session.
+ await StoryStore.shared.loadFeedFull()
}
}
diff --git a/Jarvis/Info.plist b/Jarvis/Info.plist
index 1df4112..6f4c945 100644
--- a/Jarvis/Info.plist
+++ b/Jarvis/Info.plist
@@ -5,6 +5,7 @@
BGTaskSchedulerPermittedIdentifiers
com.kisani.jarvis.briefing.refresh
+ com.kisani.jarvis.feed.refresh
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
@@ -53,7 +54,5 @@
UIInterfaceOrientationPortrait
- UIUserInterfaceStyle
- Dark
diff --git a/Jarvis/JarvisApp.swift b/Jarvis/JarvisApp.swift
index dd76a78..a5e3372 100644
--- a/Jarvis/JarvisApp.swift
+++ b/Jarvis/JarvisApp.swift
@@ -118,6 +118,9 @@ struct JarvisApp: App {
.task {
BackgroundRefreshManager.container = container
BackgroundRefreshManager.scheduleFeedRefresh()
+ // Pre-populate the feed from SwiftData cache before the first API
+ // fetch completes — prevents the cold-launch empty state.
+ store.bootstrap(container: container)
connManager.start()
await connManager.resolveAndActivate()
await notifications.bootstrap()
@@ -127,10 +130,13 @@ struct JarvisApp: App {
switch phase {
case .active:
store.startForegroundSync()
- // Refresh immediately if we've been away more than 2 minutes.
- if let last = store.lastSyncedAt,
- Date().timeIntervalSince(last) > 120 {
- Task { await store.loadStories(refresh: true) }
+ // Only trigger a load if connectivity has already been resolved.
+ // On cold launch activeHost is nil until resolveAndActivate()
+ // completes — calling loadFeedFull() before that always throws
+ // APIError.notConnected and leaves lastSyncedAt permanently nil.
+ // activate() calls loadFeedFull() itself once the host is confirmed.
+ if connManager.activeHost != nil {
+ Task { await store.loadFeedFull() }
}
case .background:
store.stopForegroundSync()
diff --git a/Jarvis/Models/Models.swift b/Jarvis/Models/Models.swift
index 1b1a64b..0f50bd1 100644
--- a/Jarvis/Models/Models.swift
+++ b/Jarvis/Models/Models.swift
@@ -178,6 +178,9 @@ final class CachedStory {
var consensus: String?
var conflict: String?
var updatedAt: Date
+ /// Original publish timestamp. Defaults to updatedAt for rows cached before
+ /// this field was added — SwiftData handles the automatic migration.
+ var createdAt: Date = Date()
var cachedAt: Date
init(from summary: StorySummary) {
@@ -191,6 +194,7 @@ final class CachedStory {
self.consensus = summary.consensus
self.conflict = summary.conflict
self.updatedAt = summary.updatedAt
+ self.createdAt = summary.createdAt
self.cachedAt = Date()
}
}
diff --git a/Jarvis/Networking/APIClient.swift b/Jarvis/Networking/APIClient.swift
index 8824a61..4b3b69e 100644
--- a/Jarvis/Networking/APIClient.swift
+++ b/Jarvis/Networking/APIClient.swift
@@ -68,12 +68,14 @@ actor APIClient {
func fetchStories(
cursor: String? = nil,
topic: String? = nil,
+ tags: Set = [],
minSignal: Int? = nil,
limit: Int = 20
) async throws -> PaginatedStories {
var params: [String: String] = ["limit": "\(limit)"]
if let cursor { params["after"] = cursor }
if let topic { params["topic"] = topic }
+ if !tags.isEmpty { params["tags"] = tags.sorted().joined(separator: ",") }
if let minSignal { params["min_signal"] = "\(minSignal)" }
return try await get("/stories", params: params)
}
diff --git a/Jarvis/Networking/WebSocketManager.swift b/Jarvis/Networking/WebSocketManager.swift
index 7560c4d..820c9c6 100644
--- a/Jarvis/Networking/WebSocketManager.swift
+++ b/Jarvis/Networking/WebSocketManager.swift
@@ -5,7 +5,7 @@ import Foundation
import Combine
enum ConnectionState {
- case disconnected
+ case disconnected
case connecting
case connected
case reconnecting(attempt: Int)
diff --git a/Jarvis/Notifications/NotificationManager.swift b/Jarvis/Notifications/NotificationManager.swift
index 049f0cb..b053fc2 100644
--- a/Jarvis/Notifications/NotificationManager.swift
+++ b/Jarvis/Notifications/NotificationManager.swift
@@ -41,6 +41,11 @@ final class NotificationManager: NSObject, ObservableObject {
func bootstrap() async {
center.delegate = self
await refreshAuthStatus()
+ // Prompt for permission on first launch (.notDetermined).
+ // Re-requesting after denial is a no-op (iOS ignores it) — safe to call every launch.
+ if authStatus == .notDetermined {
+ await requestAuthorization()
+ }
subscribeToBreakingEvents()
await applySettings()
}
diff --git a/Jarvis/Notifications/NotificationSettings.swift b/Jarvis/Notifications/NotificationSettings.swift
index af47cd8..a068f6b 100644
--- a/Jarvis/Notifications/NotificationSettings.swift
+++ b/Jarvis/Notifications/NotificationSettings.swift
@@ -51,8 +51,8 @@ final class NotificationSettings: ObservableObject {
weatherEnabled = s.weatherEnabled; locationName = s.locationName
latitude = s.latitude; longitude = s.longitude; resolvedPlace = s.resolvedPlace
} else {
- enabled = false
- breakingEnabled = true; breakingMinSignal = 80
+ enabled = true
+ breakingEnabled = true; breakingMinSignal = 65
morningEnabled = true; morningHour = 7; morningMinute = 0
eveningEnabled = true; eveningHour = 18; eveningMinute = 0
weatherEnabled = true; locationName = ""
diff --git a/Jarvis/Store/CacheMaintenance.swift b/Jarvis/Store/CacheMaintenance.swift
index 00b894b..74d4fc2 100644
--- a/Jarvis/Store/CacheMaintenance.swift
+++ b/Jarvis/Store/CacheMaintenance.swift
@@ -10,11 +10,8 @@ enum CacheMaintenance {
private static let hour: TimeInterval = 3_600
private static let day: TimeInterval = 86_400
- // Aligned to the backend's aging model:
- // RECENT_HOURS = 72 → active feed window; drop cached stories past it
- // raw-article retention ~14 days → match for cached article bodies
- private static let recentHours = 72.0 // backend active window (story list)
- private static let markerHours = 96.0 // seen/read markers: active window + buffer
+ private static let recentHours = 84.0 // 3.5 days: survives weekends
+ private static let markerHours = 96.0 // 4 days: active window + buffer
private static let articleDays = 14.0 // cached article bodies (backend article retention)
/// Age-based eviction. Run on launch. Cheap; protects saved stories.
diff --git a/Jarvis/Store/StoryStore.swift b/Jarvis/Store/StoryStore.swift
index cc1cf23..505a14b 100644
--- a/Jarvis/Store/StoryStore.swift
+++ b/Jarvis/Store/StoryStore.swift
@@ -17,23 +17,90 @@ final class StoryStore: ObservableObject {
@Published var hasMore = false
@Published var selectedTopic: String? = nil
@Published var selectedTags: Set = []
+ @Published var sectionSupplement: [String: [StorySummary]] = [:]
private var nextCursor: String?
private var refreshTask: Task?
private var foregroundSyncTask: Task?
private var cancellables = Set()
+ // MARK: - Quick cache (UserDefaults — synchronous, no async gap)
+
+ private let quickCacheKey = "jarvis.quickStories.v1"
+ private let quickCacheMaxAge: TimeInterval = 84 * 3600 // 3.5 days
+
+ /// Restore the last-known feed from UserDefaults synchronously on init.
+ /// This runs before the first SwiftUI render so the feed is never blank.
+ private func restoreQuickCache() {
+ guard let raw = UserDefaults.standard.data(forKey: quickCacheKey) else { return }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ guard let payload = try? decoder.decode(QuickCachePayload.self, from: raw) else { return }
+ // Discard if too stale — matches CacheMaintenance window.
+ guard Date().timeIntervalSince(payload.savedAt) < quickCacheMaxAge else {
+ UserDefaults.standard.removeObject(forKey: quickCacheKey)
+ return
+ }
+ if !payload.stories.isEmpty { stories = payload.stories }
+ }
+
+ /// Persist the current feed to UserDefaults so it survives process kills.
+ private func persistQuickCache() {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ let payload = QuickCachePayload(stories: Array(stories.prefix(100)), savedAt: Date())
+ if let data = try? encoder.encode(payload) {
+ UserDefaults.standard.set(data, forKey: quickCacheKey)
+ }
+ }
+
+ private struct QuickCachePayload: Codable {
+ let stories: [StorySummary]
+ let savedAt: Date
+ }
+
+ // MARK: - SwiftData bootstrap (secondary, async — covers first-ever launch before
+ // the quick cache exists, e.g. app reinstall).
+ func bootstrap(container: ModelContainer) {
+ guard stories.isEmpty else { return }
+ let ctx = ModelContext(container)
+ if let cached = try? ctx.fetch(FetchDescriptor()) {
+ let sorted = cached.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
+ if !sorted.isEmpty { stories = sorted }
+ }
+ }
+
// MARK: - Foreground sync
- /// Call when app becomes active. Polls every 5 min so the feed never runs dry.
- /// The WebSocket handles real-time events between polls.
+ private var isFetchingFull = false
+
+ /// Load page 1 then paginate to ≤100 stories.
+ /// Guarded against concurrent invocations and rate-limited to ≥30 s between
+ /// fetches so rapid foreground/background cycles don't hammer the API.
+ func loadFeedFull() async {
+ guard !isFetchingFull else { return }
+ // Rate-limit: if the last *successful* sync was < 30 s ago, skip.
+ if let last = lastSyncedAt, Date().timeIntervalSince(last) < 30 { return }
+ isFetchingFull = true
+ defer { isFetchingFull = false }
+
+ await loadStories(refresh: true)
+ var pages = 0
+ while !Task.isCancelled, hasMore, stories.count < 100, pages < 4 {
+ await loadMore()
+ pages += 1
+ }
+ loadSectionSupplements()
+ }
+
+ /// Call when app becomes active. Polls every 90 s so new stories surface fast.
func startForegroundSync() {
foregroundSyncTask?.cancel()
foregroundSyncTask = Task { [weak self] in
while !Task.isCancelled {
- try? await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000)
+ try? await Task.sleep(nanoseconds: 90 * 1_000_000_000)
guard !Task.isCancelled else { return }
- await self?.loadStories(refresh: true)
+ await self?.loadFeedFull()
}
}
}
@@ -46,6 +113,7 @@ final class StoryStore: ObservableObject {
private let api = APIClient.shared
private init() {
+ restoreQuickCache() // synchronous — stories are ready before first render
subscribeToWebSocket()
}
@@ -67,20 +135,28 @@ final class StoryStore: ObservableObject {
tags: selectedTags
)
if refresh {
+ // Replace list; count only genuinely new IDs for reshuffle logic.
let existingIds = Set(stories.map(\.id))
newCount = result.data.filter { !existingIds.contains($0.id) }.count
stories = result.data
} else {
- stories += result.data
- newCount = result.data.count
+ // Append only IDs not already present — cursor can overlap.
+ let existingIds = Set(stories.map(\.id))
+ let novel = result.data.filter { !existingIds.contains($0.id) }
+ stories += novel
+ newCount = novel.count
}
nextCursor = result.nextCursor
hasMore = result.hasMore
lastSyncedAt = Date()
+ persistQuickCache() // survives the next process kill
preCacheArticlesInBackground()
} catch let e as APIError where !e.isCancelled {
error = e
- } catch {}
+ print("[StoryStore] fetch failed: \(e.localizedDescription)")
+ } catch {
+ print("[StoryStore] unexpected fetch error: \(error)")
+ }
isLoading = false
return newCount
@@ -105,35 +181,70 @@ final class StoryStore: ObservableObject {
isLoadingMore = true
do {
let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic, tags: selectedTags)
- stories += result.data
+ // Deduplicate: cursor-based pages can overlap when stories are added mid-fetch.
+ let existingIds = Set(stories.map(\.id))
+ let novel = result.data.filter { !existingIds.contains($0.id) }
+ stories += novel
nextCursor = result.nextCursor
hasMore = result.hasMore
- } catch {}
+ } catch {
+ print("[StoryStore] loadMore error: \(error)")
+ }
isLoadingMore = false
}
- /// Coalesce bursts of `story.created` events into a single refresh so a busy
- /// feed (47 sources) doesn't fire a refetch per story.
+ /// Coalesce bursts of `story.created` WS events — waits 3 s then does a
+ /// full paginated load so stories on page 2+ are captured.
private func scheduleCoalescedRefresh() {
refreshTask?.cancel()
refreshTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 3_000_000_000)
guard !Task.isCancelled else { return }
- await self?.loadStories(refresh: true)
+ await self?.loadFeedFull()
+ }
+ }
+
+ // MARK: - Section supplements
+ // Sections with regional or niche tags rarely rank in the global top-100,
+ // so we fetch a small targeted pool for each sparse section and let
+ // makeDigest() use it as a fallback.
+
+ private let supplementSections: [(id: String, tags: Set)] = [
+ ("east-africa", ["east-africa"]),
+ ("africa", ["africa"]),
+ ("sport", ["sports", "esports", "formula-1"]),
+ ("science", ["science", "astronomy"]),
+ ("health", ["health", "health-and-wellness"]),
+ ]
+
+ func loadSectionSupplements() {
+ guard selectedTags.isEmpty else { return } // only for "All" pill
+ for sec in supplementSections {
+ let id = sec.id
+ let tags = sec.tags
+ Task { [weak self] in
+ guard let self else { return }
+ if let result = try? await api.fetchStories(tags: tags, limit: 8) {
+ await MainActor.run { self.sectionSupplement[id] = result.data }
+ }
+ }
}
}
func setTopic(_ topic: String?) async {
selectedTopic = topic
selectedTags = []
- await loadStories(refresh: true)
+ await loadFeedFull()
}
func setPill(_ pill: StoryPill) async {
selectedTopic = nil
selectedTags = pill.slugs
nextCursor = nil
- await loadStories(refresh: true)
+ // Filter changed — must fetch regardless of rate-limit, otherwise
+ // the old stories stay in place and the new pill finds almost nothing.
+ lastSyncedAt = nil
+ await loadFeedFull()
}
// MARK: - WebSocket
diff --git a/Jarvis/Views/Connectivity/ConnectivityView.swift b/Jarvis/Views/Connectivity/ConnectivityView.swift
index 16c0d14..b57ed0a 100644
--- a/Jarvis/Views/Connectivity/ConnectivityView.swift
+++ b/Jarvis/Views/Connectivity/ConnectivityView.swift
@@ -20,7 +20,7 @@ struct ConnectivityView: View {
var body: some View {
ZStack {
- Color.black.ignoresSafeArea()
+ Palette.background.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 26) {
header
@@ -44,7 +44,7 @@ struct ConnectivityView: View {
HStack {
Text("Connectivity")
.font(.system(size: 22, weight: .heavy))
- .foregroundStyle(.white)
+ .foregroundStyle(Palette.primaryText)
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark")
@@ -84,7 +84,7 @@ struct ConnectivityView: View {
VStack(alignment: .leading, spacing: 3) {
Text("Use \(providerName) when remote")
.font(.system(size: 16, weight: .heavy))
- .foregroundStyle(.white)
+ .foregroundStyle(Palette.primaryText)
Text("Connects via VPN when not on home network")
.font(.system(size: 12))
.foregroundStyle(Color(hex: "888888"))
@@ -160,7 +160,7 @@ struct ConnectivityView: View {
VStack(alignment: .leading, spacing: 3) {
Text("Auto-connect on network change")
.font(.system(size: 16, weight: .heavy))
- .foregroundStyle(.white)
+ .foregroundStyle(Palette.primaryText)
Text("Prefers the LAN on a trusted network; switches to \(providerName) when away")
.font(.system(size: 12))
.foregroundStyle(Color(hex: "888888"))
@@ -175,7 +175,7 @@ struct ConnectivityView: View {
HStack {
Text("Wait before switching")
.font(.system(size: 15, weight: .bold))
- .foregroundStyle(.white)
+ .foregroundStyle(Palette.primaryText)
Spacer()
Stepper(value: $connectivity.switchDelaySeconds, in: 1...60, step: 1) {
Text("\(connectivity.switchDelaySeconds)s")
@@ -195,7 +195,7 @@ struct ConnectivityView: View {
}
Text("iOS can't start the \(providerName) tunnel for Jarvis — keep the \(providerName) app connected when away. Jarvis only picks the reachable address.")
.font(.system(size: 11))
- .foregroundStyle(Color(hex: "666666"))
+ .foregroundStyle(Palette.tertiaryText)
.lineSpacing(2)
}
}
@@ -231,7 +231,7 @@ struct ConnectivityView: View {
Button { Task { await recheck() } } label: {
Text("Re-check")
.font(.system(size: 15, weight: .bold))
- .foregroundStyle(.white)
+ .foregroundStyle(Palette.primaryText)
.frame(maxWidth: .infinity).frame(height: 50)
.background(Palette.surface2)
.clipShape(RoundedRectangle(cornerRadius: 14))
@@ -254,7 +254,7 @@ struct ConnectivityView: View {
Text(t)
.font(.system(size: 11, weight: .bold, design: .monospaced))
.kerning(0.8)
- .foregroundStyle(Color(hex: "555555"))
+ .foregroundStyle(Palette.tertiaryText)
}
private func Card(@ViewBuilder _ content: () -> Content) -> some View {
@@ -267,7 +267,7 @@ struct ConnectivityView: View {
HStack(alignment: .center, spacing: 14) {
Image(systemName: icon)
.font(.system(size: 18, weight: .regular))
- .foregroundStyle(Color(hex: "999999"))
+ .foregroundStyle(Palette.secondaryText)
.frame(width: 26)
content()
}
@@ -287,7 +287,7 @@ struct ConnectivityView: View {
.foregroundStyle(Color(hex: "888888"))
HStack(spacing: 8) {
TextField("", text: text,
- prompt: Text(placeholder).foregroundColor(Color(hex: "444444")))
+ prompt: Text(placeholder).foregroundColor(Palette.mutedText))
.font(.system(size: 16, weight: .regular, design: .monospaced))
.foregroundStyle(Palette.orange)
.autocorrectionDisabled()
@@ -312,7 +312,7 @@ struct ConnectivityView: View {
case .reachable: return Color(hex: "33C25E")
case .unreachable: return Color(hex: "C25555")
case .checking: return Palette.healthFailing
- case .unknown: return Color(hex: "555555")
+ case .unknown: return Palette.tertiaryText
}
}
private func dot(for r: Reachability) -> some View {
diff --git a/Jarvis/Views/Feeds/AddFeedSheet.swift b/Jarvis/Views/Feeds/AddFeedSheet.swift
index f5acb0f..be42bae 100644
--- a/Jarvis/Views/Feeds/AddFeedSheet.swift
+++ b/Jarvis/Views/Feeds/AddFeedSheet.swift
@@ -21,12 +21,12 @@ struct AddFeedSheet: View {
var body: some View {
ZStack {
- Color.black.ignoresSafeArea()
+ Palette.background.ignoresSafeArea()
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Add feed")
.font(.system(size: 22, weight: .heavy))
- .foregroundStyle(.white)
+ .foregroundStyle(Palette.primaryText)
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark")
@@ -87,9 +87,9 @@ struct AddFeedSheet: View {
Text(label)
.font(.system(size: 10, weight: .bold, design: .monospaced))
.kerning(0.8)
- .foregroundStyle(Color(hex: "666666"))
+ .foregroundStyle(Palette.tertiaryText)
TextField("", text: text,
- prompt: Text(placeholder).foregroundColor(Color(hex: "444444")))
+ prompt: Text(placeholder).foregroundColor(Palette.mutedText))
.font(.system(size: 15, weight: .regular, design: mono ? .monospaced : .default))
.foregroundStyle(mono ? Palette.orange : .white)
.autocorrectionDisabled()
diff --git a/Jarvis/Views/Feeds/FeedManagerView.swift b/Jarvis/Views/Feeds/FeedManagerView.swift
index aca8720..8294772 100644
--- a/Jarvis/Views/Feeds/FeedManagerView.swift
+++ b/Jarvis/Views/Feeds/FeedManagerView.swift
@@ -86,7 +86,7 @@ struct FeedManagerView: View {
var body: some View {
ZStack {
- Color.black.ignoresSafeArea()
+ Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
header
serverCard
@@ -141,7 +141,7 @@ struct FeedManagerView: View {
Text("PLATFORM")
.font(.system(size: 9, weight: .bold, design: .monospaced))
.kerning(0.8)
- .foregroundStyle(Color(hex: "555555"))
+ .foregroundStyle(Palette.tertiaryText)
Text(settings.host ?? "not configured")
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundStyle(Palette.orange)
@@ -156,7 +156,7 @@ struct FeedManagerView: View {
}
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .bold))
- .foregroundStyle(Color(hex: "555555"))
+ .foregroundStyle(Palette.tertiaryText)
}
.padding(14)
.background(Palette.surface)
@@ -173,10 +173,10 @@ struct FeedManagerView: View {
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.font(.system(size: 14))
- .foregroundStyle(Color(hex: "555555"))
- TextField("", text: $query, prompt: Text("Search feeds").foregroundColor(Color(hex: "555555")))
+ .foregroundStyle(Palette.tertiaryText)
+ TextField("", text: $query, prompt: Text("Search feeds").foregroundColor(Palette.tertiaryText))
.font(.system(size: 15, weight: .regular))
- .foregroundStyle(.white)
+ .foregroundStyle(Palette.primaryText)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
@@ -207,7 +207,7 @@ struct FeedManagerView: View {
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
- .background(Color.black)
+ .background(Palette.background)
.refreshable { await vm.load() }
}
@@ -215,7 +215,7 @@ struct FeedManagerView: View {
Section {
ForEach(feeds) { feed in
FeedRowView(feed: feed)
- .listRowBackground(Color.black)
+ .listRowBackground(Palette.background)
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
.listRowSeparatorTint(Palette.hairline)
.swipeActions(edge: .trailing) {
@@ -230,7 +230,7 @@ struct FeedManagerView: View {
Text(title)
.font(.system(size: 10, weight: .bold, design: .monospaced))
.kerning(0.8)
- .foregroundStyle(Color(hex: "555555"))
+ .foregroundStyle(Palette.tertiaryText)
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 6, trailing: 16))
}
}
@@ -238,16 +238,16 @@ struct FeedManagerView: View {
private var loadingRow: some View {
ProgressView().tint(Palette.orange)
.frame(maxWidth: .infinity)
- .listRowBackground(Color.black)
+ .listRowBackground(Palette.background)
.listRowSeparator(.hidden)
}
private var emptyRow: some View {
Text(query.isEmpty ? (vm.error ?? "No feeds configured.") : "No feeds match “\(query)”.")
.font(.system(size: 13))
- .foregroundStyle(Color(hex: "555555"))
+ .foregroundStyle(Palette.tertiaryText)
.frame(maxWidth: .infinity, alignment: .leading)
- .listRowBackground(Color.black)
+ .listRowBackground(Palette.background)
.listRowSeparator(.hidden)
}
}
@@ -267,11 +267,11 @@ struct FeedRowView: View {
VStack(alignment: .leading, spacing: 4) {
Text(feed.name)
.font(.system(size: 15, weight: .bold))
- .foregroundStyle(.white)
+ .foregroundStyle(Palette.primaryText)
.lineLimit(1)
Text(metaLine)
.font(.system(size: 11, weight: .regular, design: .monospaced))
- .foregroundStyle(Color(hex: "666666"))
+ .foregroundStyle(Palette.tertiaryText)
.lineLimit(1)
}
diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift
index 883aa6d..ff5f635 100644
--- a/Jarvis/Views/Home/SignalFeedView.swift
+++ b/Jarvis/Views/Home/SignalFeedView.swift
@@ -33,6 +33,8 @@ struct SignalFeedView: View {
/// Live stories when connected; cached fallback while loading or offline so
/// switching to Tech / AI / F1 shows cached cards instantly.
+ /// Before the first sync of this session completes, only unread stories are
+ /// surfaced — read content is irrelevant until we know there's nothing newer.
private var filteredStories: [StorySummary] {
let base: [StorySummary]
if !store.stories.isEmpty {
@@ -40,7 +42,11 @@ struct SignalFeedView: View {
} else {
base = cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
}
- return base.filter { $0.matches(selectedPill) }
+ let matched = base.filter { $0.matches(selectedPill) }
+ if store.lastSyncedAt == nil {
+ return matched.filter { !readStoryIds.contains($0.id) }
+ }
+ return matched
}
/// The main feed: unread stories, with already-seen ones sunk below fresh ones.
@@ -59,11 +65,14 @@ struct SignalFeedView: View {
/// Every pill shows the card layout — sections are scoped to "All".
private var isDigest: Bool { true }
- /// Front-page digest: Top Stories + up to 3 per category section, deduped.
+ /// Front-page digest: Top Stories + up to 4 per category section, deduped.
+ /// Sections that score poorly in the global top-100 are supplemented from
+ /// per-section targeted fetches held in store.sectionSupplement.
private func makeDigest(sections: [NewsSection]) -> (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) {
let unread = filteredStories.filter { !readStoryIds.contains($0.id) }
let top = Array(unread.prefix(5))
var pool = Array(unread.dropFirst(5))
+ var usedIds = Set(top.map(\.id))
var out: [(NewsSection, [StorySummary])] = []
for section in sections {
var picked: [StorySummary] = [], rest: [StorySummary] = []
@@ -72,6 +81,15 @@ struct SignalFeedView: View {
else { rest.append(s) }
}
pool = rest
+ // Supplement from section-specific fetch when main pool is sparse
+ if picked.count < 2, let extra = store.sectionSupplement[section.id] {
+ for s in extra where !usedIds.contains(s.id) && s.inSection(section) {
+ picked.append(s)
+ usedIds.insert(s.id)
+ if picked.count == 4 { break }
+ }
+ }
+ usedIds.formUnion(picked.map(\.id))
if !picked.isEmpty { out.append((section, picked)) }
}
return (top, out)
@@ -102,7 +120,7 @@ struct SignalFeedView: View {
var body: some View {
NavigationStack {
ZStack {
- Color.black.ignoresSafeArea()
+ Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
header
@@ -134,39 +152,35 @@ struct SignalFeedView: View {
}
.task { CacheMaintenance.prune(modelContext) } // bound the caches on launch
.task(id: selectedPill) {
- await store.setPill(selectedPill)
- var pages = 0
- // All and Tech both use multi-section layouts that need enough stories.
- if selectedPill == .all || selectedPill == .tech {
- while store.stories.count < 100 && store.hasMore && pages < 5 {
- await store.loadMore(); pages += 1
- }
- }
+ await store.setPill(selectedPill) // paginates to 100 stories internally
}
}
// MARK: - Header
private var header: some View {
- HStack(alignment: .center) {
- JarvisWordmark(size: 30)
+ HStack(alignment: .center, spacing: 0) {
+ JarvisWordmark(size: 26)
Spacer()
- ConnectionBanner(state: ws.connectionState)
- Button {
- showFeeds = true
- } label: {
- Image(systemName: "dot.radiowaves.up.forward")
- .font(.system(size: 17, weight: .semibold))
- .foregroundStyle(Color(hex: "888888"))
- .frame(width: 44, height: 44)
- }
- Button {
- showSettings = true
- } label: {
- Image(systemName: "gearshape")
- .font(.system(size: 17, weight: .semibold))
- .foregroundStyle(Color(hex: "888888"))
- .frame(width: 44, height: 44)
+ // Connection + controls as one coherent surface
+ HStack(spacing: 2) {
+ ConnectionBanner(state: ws.connectionState)
+ Button {
+ showFeeds = true
+ } label: {
+ Image(systemName: "dot.radiowaves.up.forward")
+ .font(.system(size: 15, weight: .medium))
+ .foregroundStyle(Palette.secondaryText)
+ .frame(width: 40, height: 40)
+ }
+ Button {
+ showSettings = true
+ } label: {
+ Image(systemName: "gearshape")
+ .font(.system(size: 15, weight: .medium))
+ .foregroundStyle(Palette.secondaryText)
+ .frame(width: 40, height: 40)
+ }
}
}
.padding(.horizontal, 16)
@@ -175,18 +189,33 @@ struct SignalFeedView: View {
// MARK: - Sync bar
+ /// True while the first sync of this session hasn't completed yet, OR while
+ /// a fetch is actively in flight. Used to gate the "caught up" state so we
+ /// never declare victory before we've actually checked the server.
+ private var isSyncing: Bool { store.isLoading || store.lastSyncedAt == nil }
+
private var syncBar: some View {
- Text(syncText)
- .font(.system(size: 11, weight: .regular, design: .monospaced))
- .foregroundStyle(Color(hex: "5A5A5A"))
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 16)
- .padding(.bottom, 12)
+ HStack(spacing: 5) {
+ if isSyncing {
+ ProgressView()
+ .progressViewStyle(.circular)
+ .controlSize(.mini)
+ .tint(Palette.orange)
+ }
+ Text(isSyncing ? "Syncing signals…" : syncText)
+ .font(.system(size: 11, weight: .regular, design: .monospaced))
+ .foregroundStyle(Color(hex: "5A5A5A"))
+ .animation(.easeInOut(duration: 0.15), value: store.isLoading)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 16)
+ .padding(.bottom, 12)
}
private var syncText: String {
- let ago = syncedMinutesAgo(store.lastSyncedAt)
- let phrase = (ago == "just now" || ago == "never") ? ago : "\(ago) ago"
+ guard let last = store.lastSyncedAt else { return "Loading from cache…" }
+ let ago = last.timeAgoShort()
+ let phrase = ago == "just now" ? ago : "\(ago) ago"
if ws.connectionState.isLive {
return "Synced \(phrase) · \(cachedStoryIds.count) cached"
} else {
@@ -213,10 +242,10 @@ struct SignalFeedView: View {
private func pill(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
- .font(.system(size: 13, weight: .bold))
- .foregroundStyle(selected ? .black : Color(hex: "AAAAAA"))
- .padding(.horizontal, 14)
- .frame(height: 32)
+ .font(.system(size: 12, weight: selected ? .semibold : .medium))
+ .foregroundStyle(selected ? .black : Palette.secondaryText)
+ .padding(.horizontal, 13)
+ .frame(height: 28)
.background(selected ? Palette.orange : Palette.surface)
.clipShape(Capsule())
}
@@ -233,7 +262,7 @@ struct SignalFeedView: View {
.kerning(0.8)
}
.font(.system(size: 10, weight: .bold, design: .monospaced))
- .foregroundStyle(Color(hex: "555555"))
+ .foregroundStyle(Palette.tertiaryText)
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
@@ -246,50 +275,84 @@ struct SignalFeedView: View {
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
- .background(Color.black)
+ .background(Palette.background)
.animation(.snappy, value: showRead)
.animation(.snappy, value: readStories.count)
- .onAppear { captureSeenSnapshot() }
+ .onAppear {
+ captureSeenSnapshot()
+ // If the cache already shows all-read on first render, open the
+ // shelf immediately so the user sees their previous stories.
+ if !readItems.isEmpty && mainStories.isEmpty { showRead = true }
+ }
+ // Expand shelf when loading finishes and there are still no unread stories.
+ .onChange(of: store.isLoading) { _, loading in
+ if !loading, !readItems.isEmpty, mainStories.isEmpty {
+ withAnimation(.snappy) { showRead = true }
+ }
+ }
+ // Expand shelf if SwiftData @Query hydrates after the first render and
+ // all cached stories turn out to already be read.
+ .onChange(of: readItems.count) { _, count in
+ if count > 0, mainStories.isEmpty, !showRead {
+ showRead = true
+ }
+ }
.refreshable {
seenSnapshot = []
seenTracker.ids = []
- let new = await store.loadStories(refresh: true)
- // Nothing new from server — pull in any stories cached by background
- // refresh that aren't already in the live feed, then reshuffle.
- if new == 0 {
- let liveIds = Set(store.stories.map(\.id))
- let extra = cachedStories
- .filter { !liveIds.contains($0.id) }
- .map(StorySummary.init(cached:))
- if !extra.isEmpty {
- await store.mergeStories(extra)
- }
+ await store.loadFeedFull()
+ // Merge any stories cached by background refresh that aren't in
+ // the live feed yet — mergeStories deduplicates, so always safe.
+ let liveIds = Set(store.stories.map(\.id))
+ let extra = cachedStories
+ .filter { !liveIds.contains($0.id) }
+ .map(StorySummary.init(cached:))
+ if !extra.isEmpty {
+ await store.mergeStories(extra)
}
}
}
- /// Card layout for every pill. "All" gets full multi-section front page;
- /// topic pills get a hero card + flat list within that topic.
+ /// Card layout for every pill.
+ /// "All" → multi-section digest. Tech (sub-sections) → sub-section digest.
+ /// Everything else → hero card + flat scroll of all stories for that pill.
@ViewBuilder private var digestContent: some View {
- let activeSections = selectedPill == .all
- ? NewsSection.sections
- : (selectedPill.subSections ?? [])
- let d = makeDigest(sections: activeSections)
- let headerLabel = selectedPill == .all ? "TOP STORIES" : selectedPill.label.uppercased()
+ if selectedPill == .all {
+ let d = makeDigest(sections: NewsSection.sections)
+ sectionHeader("TOP STORIES", pill: nil)
+ pillContent(top: d.top)
+ ForEach(d.sections, id: \.0.id) { section, stories in
+ sectionHeader(section.label.uppercased(), pill: section.pill)
+ ForEach(stories) { mainRow($0) }
+ }
+ } else if let subSections = selectedPill.subSections {
+ let d = makeDigest(sections: subSections)
+ sectionHeader(selectedPill.label.uppercased(), pill: nil)
+ pillContent(top: d.top)
+ ForEach(d.sections, id: \.0.id) { section, stories in
+ sectionHeader(section.label.uppercased(), pill: section.pill)
+ ForEach(stories) { mainRow($0) }
+ }
+ } else {
+ // Flat list: hero + all stories for this pill
+ sectionHeader(selectedPill.label.uppercased(), pill: nil)
+ pillContent(top: mainStories, flat: true)
+ }
+ readShelf
+ }
- sectionHeader(headerLabel, pill: nil)
- if let lead = d.top.first {
+ /// Shared hero + body rows. `flat` = render all items; otherwise only top 5.
+ @ViewBuilder private func pillContent(top stories: [StorySummary], flat: Bool = false) -> some View {
+ if let lead = stories.first {
mainRow(lead, hero: true)
- ForEach(d.top.dropFirst()) { mainRow($0) }
+ let rest = flat ? Array(stories.dropFirst()) : Array(stories.dropFirst().prefix(4))
+ ForEach(rest) { mainRow($0) }
+ } else if !filteredStories.isEmpty {
+ if isSyncing { checkingRow.plainBlackRow() }
+ else { caughtUpRow.plainBlackRow() }
} else {
emptyState.plainBlackRow()
}
- // "All" and pills with sub-sections (Tech) get a sectioned breakdown.
- ForEach(d.sections, id: \.0.id) { section, stories in
- sectionHeader(section.label.uppercased(), pill: section.pill)
- ForEach(stories) { mainRow($0) }
- }
- readShelf
}
private func sectionHeader(_ label: String, pill: StoryPill?) -> some View {
@@ -297,7 +360,7 @@ struct SignalFeedView: View {
Text(label)
.font(.system(size: 13, weight: .heavy, design: .monospaced))
.kerning(0.6)
- .foregroundStyle(.white)
+ .foregroundStyle(Palette.primaryText)
Rectangle().fill(Palette.orange).frame(width: 18, height: 2)
Spacer()
if let pill {
@@ -326,7 +389,7 @@ struct SignalFeedView: View {
}
}
.listRowInsets(hero ? EdgeInsets(top: 2, leading: 16, bottom: 12, trailing: 16) : EdgeInsets())
- .listRowBackground(Color.black)
+ .listRowBackground(Palette.background)
.listRowSeparator(.hidden)
.onAppear {
markSeen(story)
@@ -358,24 +421,24 @@ struct SignalFeedView: View {
HStack(alignment: .top, spacing: 0) {
SignalStripe(score: story.signalScore, width: 4)
VStack(alignment: .leading, spacing: 11) {
- HStack(alignment: .firstTextBaseline) {
+ HStack(alignment: .center) {
Text(Topic.label(story.topic).uppercased())
.font(.system(size: 10, weight: .bold, design: .monospaced)).kerning(0.8)
- .foregroundStyle(Color(hex: "8A8A8A"))
+ .foregroundStyle(Palette.secondaryText)
Spacer()
Text("\(story.signalScore)")
- .font(.system(size: 21, weight: .heavy, design: .monospaced))
+ .font(.system(size: 15, weight: .semibold, design: .monospaced))
.foregroundStyle(Signal.scoreColor(story.signalScore))
}
Text(story.headline)
- .font(.system(size: 24, weight: .heavy))
- .foregroundStyle(.white)
+ .font(.system(size: 25, weight: .bold, design: .default))
+ .foregroundStyle(Palette.primaryText)
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
if !story.summary.isEmpty {
Text(story.summary)
- .font(.system(size: 14))
- .foregroundStyle(Color(hex: "9A9A9A"))
+ .font(.system(size: 16, weight: .regular, design: .default))
+ .foregroundStyle(Palette.bodyText)
.lineLimit(3).lineSpacing(2)
.fixedSize(horizontal: false, vertical: true)
}
@@ -384,7 +447,7 @@ struct SignalFeedView: View {
}
Text("\(story.sourceCount) sources · \(story.updatedAt.timeAgoShort()) ago")
.font(.system(size: 11, weight: .regular, design: .monospaced))
- .foregroundStyle(Color(hex: "666666"))
+ .foregroundStyle(Palette.tertiaryText)
}
.padding(16)
Spacer(minLength: 0)
@@ -394,11 +457,27 @@ struct SignalFeedView: View {
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Palette.surface2, lineWidth: 0.5))
}
+ /// Shown when all cached stories are read AND the first sync hasn't finished yet.
+ /// Keeps the UI honest — we haven't confirmed "no new content" until the server responds.
+ private var checkingRow: some View {
+ VStack(spacing: 8) {
+ ProgressView()
+ .progressViewStyle(.circular)
+ .controlSize(.mini)
+ .tint(Palette.orange)
+ Text("Checking for new signals…")
+ .font(.system(size: 12, weight: .bold, design: .monospaced))
+ .foregroundStyle(Palette.tertiaryText)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 50)
+ }
+
private var caughtUpRow: some View {
VStack(spacing: 10) {
Image(systemName: "checkmark.circle")
.font(.system(size: 32)).foregroundStyle(Color(hex: "3E7E3E"))
- Text("You're all caught up").font(.system(size: 16, weight: .heavy)).foregroundStyle(.white)
+ Text("You're all caught up").font(.system(size: 16, weight: .semibold, design: .default)).foregroundStyle(Palette.primaryText)
Text("Pull to refresh for more")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
}
@@ -423,7 +502,7 @@ struct SignalFeedView: View {
Image(systemName: showRead ? "chevron.up" : "chevron.down")
.font(.system(size: 11, weight: .bold))
}
- .foregroundStyle(Color(hex: "666666"))
+ .foregroundStyle(Palette.tertiaryText)
.padding(.horizontal, 16).padding(.vertical, 16)
.contentShape(Rectangle())
}
@@ -455,8 +534,8 @@ struct SignalFeedView: View {
private var emptyState: some View {
VStack(spacing: 12) {
- if store.isLoading {
- // Actively polling the server.
+ if isSyncing {
+ // First sync hasn't completed yet, or a fetch is actively in flight.
ProgressView().tint(Palette.orange)
Text("Polling signals…")
.font(.system(size: 14, weight: .heavy))
@@ -466,7 +545,7 @@ struct SignalFeedView: View {
// Reached the empty state because the fetch failed.
emptyIcon("exclamationmark.triangle", color: Color(hex: "C25555"))
Text("Couldn't reach the server")
- .font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
+ .font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
Text(error.errorDescription ?? "Unknown error")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
.multilineTextAlignment(.center)
@@ -474,14 +553,14 @@ struct SignalFeedView: View {
refreshButton("Retry")
} else if !ws.connectionState.isLive {
emptyIcon("wifi.slash")
- Text("Offline").font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
+ Text("Offline").font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
Text("No cached stories to show")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
refreshButton("Retry")
} else if selectedPill != .all {
emptyIcon("line.3.horizontal.decrease.circle")
Text("No \(selectedPill.label) stories yet")
- .font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
+ .font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
if store.isLoadingMore || store.hasMore {
Text("Pulling more to find them…")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
@@ -494,7 +573,7 @@ struct SignalFeedView: View {
} else {
// Connected, loaded, genuinely nothing yet.
emptyIcon("antenna.radiowaves.left.and.right")
- Text("No signals yet").font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
+ Text("No signals yet").font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
Text("Pull to refresh")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
refreshButton("Refresh")
@@ -505,7 +584,7 @@ struct SignalFeedView: View {
.padding(.top, 80)
}
- private func emptyIcon(_ name: String, color: Color = Color(hex: "333333")) -> some View {
+ private func emptyIcon(_ name: String, color: Color = Palette.mutedText) -> some View {
Image(systemName: name).font(.system(size: 30)).foregroundStyle(color)
}
@@ -513,13 +592,13 @@ struct SignalFeedView: View {
if let host = settings.host {
Text(host)
.font(.system(size: 11, design: .monospaced))
- .foregroundStyle(Color(hex: "555555"))
+ .foregroundStyle(Palette.tertiaryText)
}
}
private func refreshButton(_ title: String) -> some View {
Button {
- Task { await store.loadStories(refresh: true) }
+ Task { await store.loadFeedFull() }
} label: {
Text(title)
.font(.system(size: 14, weight: .bold)).foregroundStyle(.black)
@@ -611,7 +690,7 @@ extension StorySummary {
consensus: cached.consensus,
conflict: cached.conflict,
updatedAt: cached.updatedAt,
- createdAt: cached.updatedAt
+ createdAt: cached.createdAt
)
}
}
@@ -620,7 +699,7 @@ private extension View {
/// Full-bleed black List row with no separator — keeps the flat feed look.
func plainBlackRow() -> some View {
self.listRowInsets(EdgeInsets())
- .listRowBackground(Color.black)
+ .listRowBackground(Palette.background)
.listRowSeparator(.hidden)
}
}
diff --git a/Jarvis/Views/Home/StoryRowView.swift b/Jarvis/Views/Home/StoryRowView.swift
index 4bec2e0..1cf2e9d 100644
--- a/Jarvis/Views/Home/StoryRowView.swift
+++ b/Jarvis/Views/Home/StoryRowView.swift
@@ -22,8 +22,8 @@ struct StoryRowView: View {
HStack(spacing: 0) {
SignalStripe(score: story.signalScore, muted: true)
Text(story.headline)
- .font(.system(size: 14, weight: .bold))
- .foregroundStyle(Color(hex: "6A6A6A"))
+ .font(.system(size: 15, weight: .semibold, design: .default))
+ .foregroundStyle(Palette.secondaryText)
.lineLimit(1)
.padding(.leading, 14)
.padding(.vertical, 11)
@@ -34,7 +34,7 @@ struct StoryRowView: View {
.padding(.trailing, 16)
}
.frame(maxWidth: .infinity, alignment: .leading)
- .background(Color.black)
+ .background(Palette.background)
.overlay(alignment: .bottom) { Rectangle().fill(Palette.hairline).frame(height: 0.5) }
.contentShape(Rectangle())
}
@@ -49,54 +49,48 @@ struct StoryRowView: View {
}
VStack(alignment: .leading, spacing: 9) {
- // Title (+ cached dot)
- HStack(alignment: .top, spacing: 8) {
- Text(story.headline)
- .font(.system(size: 17, weight: .heavy))
- .foregroundStyle(Signal.titleColor(story.signalScore))
- .lineLimit(3)
- .fixedSize(horizontal: false, vertical: true)
+ Text(story.headline)
+ .font(.system(size: 19, weight: .bold, design: .default))
+ .foregroundStyle(Signal.titleColor(story.signalScore))
+ .lineLimit(3)
+ .fixedSize(horizontal: false, vertical: true)
- if isCached {
- CachedDot()
- .padding(.top, 6)
- }
- }
-
- // Summary preview — the gist, without opening the story.
if !story.summary.isEmpty {
Text(story.summary)
- .font(.system(size: 13, weight: .regular))
- .foregroundStyle(Color(hex: "8A8A8A"))
- .lineLimit(6)
- .lineSpacing(2)
+ .font(.system(size: 15, weight: .regular, design: .default))
+ .foregroundStyle(Palette.bodyText)
+ .lineLimit(5)
+ .lineSpacing(3)
.fixedSize(horizontal: false, vertical: true)
}
- // Source chips
if !sourceNames.isEmpty {
SourceChips(names: sourceNames, total: story.sourceCount)
}
- // Metadata
Text(metaLine)
.font(.system(size: 11, weight: .regular, design: .monospaced))
- .foregroundStyle(Color(hex: "666666"))
+ .foregroundStyle(Palette.tertiaryText)
}
.padding(.leading, 14)
.padding(.vertical, 16)
Spacer(minLength: 12)
- // Signal score column
- Text("\(story.signalScore)")
- .font(.system(size: 22, weight: .heavy, design: .monospaced))
- .foregroundStyle(Signal.scoreColor(story.signalScore))
- .padding(.trailing, 16)
- .padding(.top, 16)
+ // Signal cluster: score + offline dot, compact and aligned
+ VStack(alignment: .trailing, spacing: 4) {
+ Text("\(story.signalScore)")
+ .font(.system(size: 14, weight: .semibold, design: .monospaced))
+ .foregroundStyle(Signal.scoreColor(story.signalScore))
+ if isCached {
+ CachedDot(size: 6)
+ }
+ }
+ .padding(.trailing, 16)
+ .padding(.top, 18)
}
.frame(maxWidth: .infinity, alignment: .leading)
- .background(Color.black)
+ .background(Palette.background)
.overlay(alignment: .bottom) {
Rectangle().fill(Palette.hairline).frame(height: 0.5)
}
@@ -128,5 +122,5 @@ struct StoryRowView: View {
)
}
}
- .background(Color.black)
+ .background(Palette.background)
}
diff --git a/Jarvis/Views/Onboarding/OnboardingView.swift b/Jarvis/Views/Onboarding/OnboardingView.swift
index 82ef826..2bbd1e4 100644
--- a/Jarvis/Views/Onboarding/OnboardingView.swift
+++ b/Jarvis/Views/Onboarding/OnboardingView.swift
@@ -11,7 +11,7 @@ struct OnboardingView: View {
var body: some View {
ZStack {
- Color.black.ignoresSafeArea()
+ Palette.background.ignoresSafeArea()
VStack(alignment: .leading, spacing: 0) {
Spacer().frame(height: 48)
diff --git a/Jarvis/Views/Reader/ArticleReaderView.swift b/Jarvis/Views/Reader/ArticleReaderView.swift
index 2592ac7..b8583bf 100644
--- a/Jarvis/Views/Reader/ArticleReaderView.swift
+++ b/Jarvis/Views/Reader/ArticleReaderView.swift
@@ -175,7 +175,7 @@ struct ArticleReaderView: View {
HStack(spacing: 10) {
Text(article.source)
.font(.system(size: 11, weight: .bold))
- .foregroundStyle(.white)
+ .foregroundStyle(Palette.primaryText)
.lineLimit(1)
.padding(.horizontal, 10).padding(.vertical, 5)
.background(Palette.orange)
diff --git a/Jarvis/Views/SecondaryTabs.swift b/Jarvis/Views/SecondaryTabs.swift
index 6aba1a5..b484819 100644
--- a/Jarvis/Views/SecondaryTabs.swift
+++ b/Jarvis/Views/SecondaryTabs.swift
@@ -21,7 +21,7 @@ private struct TabHeader: View {
JarvisWordmark(size: 26)
Text(title)
.font(.system(size: 13, weight: .bold))
- .foregroundStyle(Color(hex: "666666"))
+ .foregroundStyle(Palette.tertiaryText)
.padding(.top, 6)
Spacer()
}
@@ -65,7 +65,7 @@ struct LatestView: View {
var body: some View {
NavigationStack {
ZStack {
- Color.black.ignoresSafeArea()
+ Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
TabHeader(title: "Latest")
Divider().overlay(Palette.hairline)
@@ -98,20 +98,20 @@ struct SavedView: View {
var body: some View {
NavigationStack {
ZStack {
- Color.black.ignoresSafeArea()
+ Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
TabHeader(title: "Saved")
Divider().overlay(Palette.hairline)
if stories.isEmpty {
VStack(spacing: 12) {
Image(systemName: "bookmark")
- .font(.system(size: 28)).foregroundStyle(Color(hex: "333333"))
+ .font(.system(size: 28)).foregroundStyle(Palette.mutedText)
Text("Nothing saved yet")
.font(.system(size: 14, weight: .bold))
- .foregroundStyle(Color(hex: "555555"))
+ .foregroundStyle(Palette.tertiaryText)
Text("Swipe a story left to save it")
.font(.system(size: 12))
- .foregroundStyle(Color(hex: "444444"))
+ .foregroundStyle(Palette.mutedText)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
@@ -145,7 +145,7 @@ struct SearchView: View {
var body: some View {
NavigationStack {
ZStack {
- Color.black.ignoresSafeArea()
+ Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
TabHeader(title: "Search")
searchBar
@@ -168,11 +168,11 @@ struct SearchView: View {
private var searchBar: some View {
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
- .font(.system(size: 14)).foregroundStyle(Color(hex: "555555"))
+ .font(.system(size: 14)).foregroundStyle(Palette.tertiaryText)
TextField("", text: $query,
- prompt: Text("Search").foregroundColor(Color(hex: "555555")))
+ prompt: Text("Search").foregroundColor(Palette.tertiaryText))
.font(.system(size: 15))
- .foregroundStyle(.white)
+ .foregroundStyle(Palette.primaryText)
.autocorrectionDisabled()
}
.padding(.horizontal, 14)
@@ -186,7 +186,7 @@ struct SearchView: View {
private func placeholder(_ text: String) -> some View {
Text(text)
.font(.system(size: 14, weight: .bold))
- .foregroundStyle(Color(hex: "555555"))
+ .foregroundStyle(Palette.tertiaryText)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
diff --git a/Jarvis/Views/Settings/NotificationsView.swift b/Jarvis/Views/Settings/NotificationsView.swift
index 4027bef..1486208 100644
--- a/Jarvis/Views/Settings/NotificationsView.swift
+++ b/Jarvis/Views/Settings/NotificationsView.swift
@@ -15,7 +15,7 @@ struct NotificationsView: View {
var body: some View {
ZStack {
- Color.black.ignoresSafeArea()
+ Palette.background.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 24) {
header
@@ -46,7 +46,7 @@ struct NotificationsView: View {
// MARK: - Header
private var header: some View {
- Text("Notifications").font(.system(size: 22, weight: .heavy)).foregroundStyle(.white)
+ Text("Notifications").font(.system(size: 22, weight: .heavy)).foregroundStyle(Palette.primaryText)
.padding(.top, 4)
}
@@ -78,8 +78,8 @@ struct NotificationsView: View {
divider
HStack(spacing: 14) {
Image(systemName: "dial.medium").font(.system(size: 18))
- .foregroundStyle(Color(hex: "999999")).frame(width: 26)
- Text("Only above signal").font(.system(size: 15, weight: .bold)).foregroundStyle(.white)
+ .foregroundStyle(Palette.secondaryText).frame(width: 26)
+ Text("Only above signal").font(.system(size: 15, weight: .bold)).foregroundStyle(Palette.primaryText)
Spacer()
Stepper(value: $settings.breakingMinSignal, in: 0...100, step: 5) {}
.labelsHidden().fixedSize()
@@ -103,8 +103,8 @@ struct NotificationsView: View {
divider
HStack(spacing: 14) {
Image(systemName: "clock").font(.system(size: 18))
- .foregroundStyle(Color(hex: "999999")).frame(width: 26)
- Text("Time").font(.system(size: 15, weight: .bold)).foregroundStyle(.white)
+ .foregroundStyle(Palette.secondaryText).frame(width: 26)
+ Text("Time").font(.system(size: 15, weight: .bold)).foregroundStyle(Palette.primaryText)
Spacer()
DatePicker("", selection: timeBinding(hour: hour, minute: minute),
displayedComponents: .hourAndMinute)
@@ -142,13 +142,13 @@ struct NotificationsView: View {
divider
HStack(spacing: 14) {
Image(systemName: "mappin.and.ellipse").font(.system(size: 18))
- .foregroundStyle(Color(hex: "999999")).frame(width: 26)
+ .foregroundStyle(Palette.secondaryText).frame(width: 26)
VStack(alignment: .leading, spacing: 3) {
Text("Location").font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
TextField("", text: $settings.locationName,
- prompt: Text("Kampala").foregroundColor(Color(hex: "444444")))
+ prompt: Text("Kampala").foregroundColor(Palette.mutedText))
.font(.system(size: 16, weight: .regular))
- .foregroundStyle(.white)
+ .foregroundStyle(Palette.primaryText)
.autocorrectionDisabled()
.onSubmit { resolveLocation() }
if let place = settings.resolvedPlace {
@@ -180,7 +180,7 @@ struct NotificationsView: View {
private var testButton: some View {
Button { Task { await manager.sendTestBriefing() } } label: {
Text("Send a test briefing")
- .font(.system(size: 15, weight: .bold)).foregroundStyle(.white)
+ .font(.system(size: 15, weight: .bold)).foregroundStyle(Palette.primaryText)
.frame(maxWidth: .infinity).frame(height: 50)
.background(Palette.surface2).clipShape(RoundedRectangle(cornerRadius: 14))
}
@@ -193,7 +193,7 @@ struct NotificationsView: View {
private func section(_ label: String, @ViewBuilder _ content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(label).font(.system(size: 11, weight: .bold, design: .monospaced))
- .kerning(0.8).foregroundStyle(Color(hex: "555555"))
+ .kerning(0.8).foregroundStyle(Palette.tertiaryText)
VStack(spacing: 0) { content() }
.background(Palette.surface).clipShape(RoundedRectangle(cornerRadius: 14))
}
@@ -201,10 +201,10 @@ struct NotificationsView: View {
private func toggleRow(icon: String, title: String, subtitle: String, isOn: Binding) -> some View {
HStack(spacing: 14) {
- Image(systemName: icon).font(.system(size: 18)).foregroundStyle(Color(hex: "999999")).frame(width: 26)
+ Image(systemName: icon).font(.system(size: 18)).foregroundStyle(Palette.secondaryText).frame(width: 26)
Toggle(isOn: isOn) {
VStack(alignment: .leading, spacing: 3) {
- Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(.white)
+ Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(Palette.primaryText)
Text(subtitle).font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
.fixedSize(horizontal: false, vertical: true)
}
diff --git a/Jarvis/Views/Settings/SettingsView.swift b/Jarvis/Views/Settings/SettingsView.swift
index b806bf6..295ab42 100644
--- a/Jarvis/Views/Settings/SettingsView.swift
+++ b/Jarvis/Views/Settings/SettingsView.swift
@@ -8,6 +8,7 @@ struct SettingsView: View {
@EnvironmentObject var settings: ServerSettings
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
+ @AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .dark
@Query private var cachedStories: [CachedStory]
@Query private var cachedArticles: [CachedArticle]
@State private var showClearConfirm = false
@@ -15,11 +16,15 @@ struct SettingsView: View {
var body: some View {
NavigationStack {
ZStack {
- Color.black.ignoresSafeArea()
+ Palette.background.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 26) {
header
+ group("APPEARANCE") {
+ appearancePicker
+ }
+
group("GENERAL") {
NavigationLink { NotificationsView() } label: {
row(icon: "bell.badge", title: "Notifications",
@@ -60,13 +65,47 @@ struct SettingsView: View {
}
}
+ private var appearancePicker: some View {
+ HStack(spacing: 14) {
+ Image(systemName: "circle.lefthalf.filled")
+ .font(.system(size: 18))
+ .foregroundStyle(Palette.secondaryText)
+ .frame(width: 26)
+ Text("Theme")
+ .font(.system(size: 16, weight: .heavy))
+ .foregroundStyle(Palette.primaryText)
+ Spacer()
+ HStack(spacing: 0) {
+ ForEach(AppearanceMode.allCases, id: \.self) { mode in
+ Button {
+ appearanceMode = mode
+ } label: {
+ Text(mode.label)
+ .font(.system(size: 12, weight: .bold))
+ .foregroundStyle(appearanceMode == mode ? .black : Palette.secondaryText)
+ .padding(.horizontal, 12)
+ .frame(height: 30)
+ .background(appearanceMode == mode ? Palette.orange : Color.clear)
+ .clipShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .background(Palette.surface2)
+ .clipShape(Capsule())
+ .overlay(Capsule().stroke(Palette.surface2, lineWidth: 0.5))
+ }
+ .padding(.horizontal, 14).padding(.vertical, 14)
+ .contentShape(Rectangle())
+ }
+
private var header: some View {
HStack {
- Text("Settings").font(.system(size: 22, weight: .heavy)).foregroundStyle(.white)
+ Text("Settings").font(.system(size: 22, weight: .heavy)).foregroundStyle(Palette.primaryText)
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark").font(.system(size: 15, weight: .bold))
- .foregroundStyle(Color(hex: "888888")).frame(width: 44, height: 44)
+ .foregroundStyle(Palette.secondaryText).frame(width: 44, height: 44)
}
}
.padding(.top, 8)
@@ -75,7 +114,7 @@ struct SettingsView: View {
private func group(_ label: String, @ViewBuilder _ content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(label).font(.system(size: 11, weight: .bold, design: .monospaced))
- .kerning(0.8).foregroundStyle(Color(hex: "555555"))
+ .kerning(0.8).foregroundStyle(Palette.tertiaryText)
VStack(spacing: 0) { content() }
.background(Palette.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
@@ -83,18 +122,19 @@ struct SettingsView: View {
}
private func row(icon: String, title: String, subtitle: String,
- chevron: Bool = true, tint: Color = .white) -> some View {
- HStack(spacing: 14) {
+ chevron: Bool = true, tint: Color? = nil) -> some View {
+ let resolvedTint = tint ?? Palette.primaryText
+ return HStack(spacing: 14) {
Image(systemName: icon).font(.system(size: 18))
- .foregroundStyle(tint == .white ? Color(hex: "999999") : tint).frame(width: 26)
+ .foregroundStyle(tint == nil ? Palette.secondaryText : resolvedTint).frame(width: 26)
VStack(alignment: .leading, spacing: 2) {
- Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(tint).lineLimit(1)
- Text(subtitle).font(.system(size: 12)).foregroundStyle(Color(hex: "888888")).lineLimit(1)
+ Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(resolvedTint).lineLimit(1)
+ Text(subtitle).font(.system(size: 12)).foregroundStyle(Palette.secondaryText).lineLimit(1)
}
Spacer()
if chevron {
Image(systemName: "chevron.right").font(.system(size: 13, weight: .bold))
- .foregroundStyle(Color(hex: "555555"))
+ .foregroundStyle(Palette.tertiaryText)
}
}
.padding(.horizontal, 14).padding(.vertical, 14).frame(minHeight: 44)
diff --git a/Jarvis/Views/Shared/CachedBadge.swift b/Jarvis/Views/Shared/CachedBadge.swift
index 1e89caa..680d0c1 100644
--- a/Jarvis/Views/Shared/CachedBadge.swift
+++ b/Jarvis/Views/Shared/CachedBadge.swift
@@ -39,5 +39,5 @@ struct CachedBadge: View {
CachedBadge(text: "All 7 articles cached · available offline")
}
.padding()
- .background(Color.black)
+ .background(Palette.background)
}
diff --git a/Jarvis/Views/Shared/ConnectionBanner.swift b/Jarvis/Views/Shared/ConnectionBanner.swift
index 7574161..a366561 100644
--- a/Jarvis/Views/Shared/ConnectionBanner.swift
+++ b/Jarvis/Views/Shared/ConnectionBanner.swift
@@ -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)
}
diff --git a/Jarvis/Views/Shared/SignalStripe.swift b/Jarvis/Views/Shared/SignalStripe.swift
index cfe2071..4689c19 100644
--- a/Jarvis/Views/Shared/SignalStripe.swift
+++ b/Jarvis/Views/Shared/SignalStripe.swift
@@ -29,5 +29,5 @@ struct SignalStripe: View {
}
}
.padding()
- .background(Color.black)
+ .background(Palette.background)
}
diff --git a/Jarvis/Views/Shared/SourceChips.swift b/Jarvis/Views/Shared/SourceChips.swift
index 7db6033..d386a77 100644
--- a/Jarvis/Views/Shared/SourceChips.swift
+++ b/Jarvis/Views/Shared/SourceChips.swift
@@ -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)
}
diff --git a/Jarvis/Views/Shared/Theme.swift b/Jarvis/Views/Shared/Theme.swift
index b0ffc03..8691f80 100644
--- a/Jarvis/Views/Shared/Theme.swift
+++ b/Jarvis/Views/Shared/Theme.swift
@@ -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
+ )
}
- // 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)
+ 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 {
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),
+ ]
}
diff --git a/Jarvis/Views/Story/StoryDetailView.swift b/Jarvis/Views/Story/StoryDetailView.swift
index d0f0b2d..6cac6b4 100644
--- a/Jarvis/Views/Story/StoryDetailView.swift
+++ b/Jarvis/Views/Story/StoryDetailView.swift
@@ -69,20 +69,21 @@ struct StoryDetailView: View {
var body: some View {
ZStack {
- Color.black.ignoresSafeArea()
+ Palette.background.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
categoryRow
Text(headline)
- .font(.system(size: 28, weight: .heavy))
- .foregroundStyle(.white)
+ .font(.system(size: 31, weight: .bold, design: .default))
+ .lineSpacing(2)
+ .foregroundStyle(Palette.primaryText)
.fixedSize(horizontal: false, vertical: true)
if !summary.isEmpty {
Text(summary)
- .font(.system(size: 15, weight: .regular))
- .foregroundStyle(Color(hex: "9A9A9A"))
- .lineSpacing(4)
+ .font(.system(size: 18, weight: .regular, design: .default))
+ .foregroundStyle(Palette.secondaryText)
+ .lineSpacing(5)
}
SourceChips(names: sourceNames, total: sourceCount)
@@ -123,7 +124,7 @@ struct StoryDetailView: View {
}
}
}
- .toolbarBackground(Color.black, for: .navigationBar)
+ .toolbarBackground(Palette.background, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
.task {
@@ -195,7 +196,7 @@ struct StoryDetailView: View {
.font(.system(size: 11, weight: .bold, design: .monospaced))
.kerning(0.8)
.foregroundStyle(Color(hex: "888888"))
- Text("·").foregroundStyle(Color(hex: "444444"))
+ Text("·").foregroundStyle(Palette.mutedText)
Text("\(score) signal")
.font(.system(size: 11, weight: .bold, design: .monospaced))
.foregroundStyle(Signal.scoreColor(score))
@@ -228,8 +229,8 @@ struct StoryDetailView: View {
.kerning(0.8)
.foregroundStyle(titleColor)
Text(text)
- .font(.system(size: 14, weight: .regular))
- .foregroundStyle(Color(hex: "C8C8C8"))
+ .font(.system(size: 16, weight: .regular, design: .default))
+ .foregroundStyle(Palette.secondaryText)
.lineSpacing(3)
.fixedSize(horizontal: false, vertical: true)
}
@@ -245,7 +246,7 @@ struct StoryDetailView: View {
Text("COVERAGE TIMELINE")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.kerning(0.8)
- .foregroundStyle(Color(hex: "555555"))
+ .foregroundStyle(Palette.tertiaryText)
.padding(.bottom, 14)
if vm.isLoading && timeline.isEmpty {
@@ -253,7 +254,7 @@ struct StoryDetailView: View {
} else if timeline.isEmpty {
Text(vm.error ?? "No coverage available.")
.font(.system(size: 13))
- .foregroundStyle(Color(hex: "555555"))
+ .foregroundStyle(Palette.tertiaryText)
} else {
ForEach(Array(timeline.enumerated()), id: \.element.id) { index, entry in
NavigationLink(value: ArticleRoute(articleId: entry.articleId,
@@ -275,11 +276,11 @@ struct StoryDetailView: View {
// Node + connector
VStack(spacing: 0) {
Circle()
- .fill(isFirst ? Palette.orange : Color(hex: "333333"))
+ .fill(isFirst ? Palette.orange : Palette.mutedText)
.frame(width: 11, height: 11)
- .overlay(Circle().stroke(Color.black, lineWidth: 2))
+ .overlay(Circle().stroke(Palette.background, lineWidth: 2))
if !isLast {
- Rectangle().fill(Color(hex: "222222")).frame(width: 1.5)
+ Rectangle().fill(Palette.hairline).frame(width: 1.5)
}
}
.frame(width: 11)
@@ -287,12 +288,12 @@ struct StoryDetailView: View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 8) {
Text(entry.source)
- .font(.system(size: 12, weight: .bold))
- .foregroundStyle(isFirst ? Palette.orange : Color(hex: "999999"))
+ .font(.system(size: 14, weight: .semibold, design: .default))
+ .foregroundStyle(isFirst ? Palette.orange : Palette.primaryText)
if entry.isBreaking {
Text("BREAKING")
.font(.system(size: 9, weight: .bold, design: .monospaced))
- .foregroundStyle(.white)
+ .foregroundStyle(Palette.primaryText)
.padding(.horizontal, 5).padding(.vertical, 2)
.background(Palette.conflictBorder)
.clipShape(Capsule())
@@ -300,12 +301,12 @@ struct StoryDetailView: View {
Spacer()
Text(entry.publishedAt.clockShort)
.font(.system(size: 11, weight: .regular, design: .monospaced))
- .foregroundStyle(Color(hex: "555555"))
+ .foregroundStyle(Palette.tertiaryText)
if cached { CachedDot(size: 6) }
}
Text(entry.headline)
- .font(.system(size: 15, weight: .regular))
- .foregroundStyle(Color(hex: "D0D0D0"))
+ .font(.system(size: 16, weight: .regular, design: .default))
+ .foregroundStyle(Palette.secondaryText)
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
}
diff --git a/project.yml b/project.yml
index e6d162f..6ca6b64 100644
--- a/project.yml
+++ b/project.yml
@@ -20,7 +20,6 @@ targets:
UIColorName: ""
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIUserInterfaceStyle: Dark
NSLocalNetworkUsageDescription: "Jarvis connects to your self-hosted news platform on the local network."
NSAppTransportSecurity:
NSAllowsArbitraryLoads: true