Files
jarvis/Jarvis/Views/Shared/Theme.swift
Robin Kutesa 27d0e22767 Initial commit: Jarvis iOS app
SwiftUI client for a self-hosted RSS news-correlation platform: signal feed,
story detail, article reader, feed manager, and LAN⇄Tailscale connectivity.
Project generated from project.yml via XcodeGen.

Includes CI build matrix (macOS 14/15 × Debug/Release), issue templates,
backlog, and API/backend handoff docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:04:59 +03:00

163 lines
5.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

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

// Theme.swift
// Jarvis shared design tokens, signal-score fade math, formatting, nav routes.
// Every hex value here is from the spec; nothing is invented.
import SwiftUI
// MARK: - Hex colors
extension Color {
init(hex: String) {
let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
var v: UInt64 = 0
Scanner(string: s).scanHexInt64(&v)
let r, g, b: Double
switch s.count {
case 6:
r = Double((v >> 16) & 0xFF) / 255
g = Double((v >> 8) & 0xFF) / 255
b = Double(v & 0xFF) / 255
default:
r = 0; g = 0; b = 0
}
self = Color(.sRGB, red: r, green: g, blue: b, opacity: 1)
}
}
// MARK: - Palette
enum Palette {
static let orange = Color("KisaniOrange") // #FF5C00
static let black = Color.black // #000000
static let surface = Color(hex: "111111")
static let surface2 = Color(hex: "1A1A1A")
static let hairline = Color(hex: "1A1A1A")
// health
static let healthActive = Color(hex: "2A5A2A")
static let healthFailing = Color(hex: "AA6600")
static let healthDead = Color(hex: "5A1A1A")
// blocks
static let consensusBorder = Color(hex: "FF5C00")
static let consensusFill = Color(hex: "1F1206") // dark orange
static let conflictBorder = Color(hex: "5A1A1A") // dark red
static let conflictFill = Color(hex: "1A0C0C") // dark red
static let cachedGreen = Color(hex: "2A5A2A")
static let bodyText = Color(hex: "4A4A4A")
}
// MARK: - Signal-score fade
//
// Spec anchors:
// stripe : 97 #FF5C00, fades to #1A1A1A at 0
// title : 97 white, 20 #444444, 019 near invisible
// score : 80100 full orange · 5079 dimmed orange · 2049 dark brown · 019 near invisible
enum Signal {
private static func lerp(_ a: (Double, Double, Double),
_ b: (Double, Double, Double),
_ t: Double) -> Color {
let t = max(0, min(1, t))
return Color(.sRGB,
red: a.0 + (b.0 - a.0) * t,
green: a.1 + (b.1 - a.1) * t,
blue: a.2 + (b.2 - a.2) * t,
opacity: 1)
}
/// 3pt left stripe color: #1A1A1A (0) #FF5C00 (97+).
static func stripeColor(_ score: Int) -> Color {
let t = Double(score) / 97.0
return lerp((0x1A/255, 0x1A/255, 0x1A/255), // #1A1A1A
(0xFF/255, 0x5C/255, 0x00/255), // #FF5C00
t)
}
/// Story title color: white (97+) #444444 (20) near invisible (0).
static func titleColor(_ score: Int) -> Color {
if score >= 97 { return .white }
if score >= 20 {
let t = Double(score - 20) / Double(97 - 20)
return lerp((0x44/255, 0x44/255, 0x44/255), // #444444
(1, 1, 1), // white
t)
}
// 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)
}
/// Signal score number color: stays in orange/brown hue, fades with score.
/// near-invisible brown (0) full #FF5C00 (100).
static func scoreColor(_ score: Int) -> Color {
let t = Double(score) / 100.0
return lerp((0x2A/255, 0x18/255, 0x10/255), // near-invisible brown
(0xFF/255, 0x5C/255, 0x00/255), // #FF5C00
t)
}
}
// MARK: - Topic display
enum Topic {
static func label(_ slug: String) -> String {
switch slug.lowercased() {
case "finance": return "Finance"
case "tech": return "Tech"
case "politics": return "Politics"
case "africa": return "Africa"
default: return slug.prefix(1).uppercased() + slug.dropFirst()
}
}
/// Topic pills shown on the home screen.
static let filters: [(label: String, slug: String?)] = [
("All", nil),
("Finance", "finance"),
("Tech", "tech"),
("Politics", "politics"),
("Africa", "africa"),
]
}
// MARK: - Time formatting
extension Date {
/// Short relative string e.g. "just now", "3 min", "2 hr", "4 d".
func timeAgoShort(reference: Date = Date()) -> String {
let secs = max(0, Int(reference.timeIntervalSince(self)))
switch secs {
case 0..<60: return "just now"
case 60..<3600: return "\(secs / 60) min"
case 3600..<86400: return "\(secs / 3600) hr"
default: return "\(secs / 86400) d"
}
}
/// "08:03" style monospaced clock for timeline / source chips.
var clockShort: String {
let f = DateFormatter()
f.dateFormat = "HH:mm"
return f.string(from: self)
}
}
func syncedMinutesAgo(_ date: Date?, reference: Date = Date()) -> String {
guard let date else { return "never" }
return date.timeAgoShort(reference: reference)
}
// MARK: - Navigation routes
/// Route into the article reader. Carries the parent story context so the
/// back button can show the truncated headline.
struct ArticleRoute: Hashable {
let articleId: String
let storyId: String
let parentHeadline: String
}