chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app is drawn in knotless's themes, the account's own included
ios/Knotless/API/Account.swift+7−7
| @@ -199,10 +199,10 @@ struct ReauthNote: View { | ||
} label: { | ||
Text(busy ? "Signing in…" : "Sign in again") | ||
.font(.subheadline.weight(.semibold)) | ||
.foregroundStyle(Color(.systemBackground)) | ||
.foregroundStyle(Color.page) | ||
.padding(.horizontal, Space.s4) | ||
.padding(.vertical, Space.s2) | ||
.background(Color.primary.opacity(busy ? 0.4 : 1), in: Capsule()) | ||
.background(Color.fg.opacity(busy ? 0.4 : 1), in: Capsule()) | ||
.contentShape(Capsule()) | ||
} | ||
.buttonStyle(.plain) | ||
| @@ -263,20 +263,20 @@ struct SignInForm: View { | ||
.onSubmit(go) | ||
.padding(.horizontal, Space.s5) | ||
.frame(minHeight: Size.signIn) | ||
.background(Color(.secondarySystemBackground), in: Capsule()) | ||
.background(Color.raised, in: Capsule()) | ||
.accessibilityLabel("Your handle") | ||
| ||
Button(action: go) { | ||
Group { | ||
if busy { | ||
ProgressView().tint(Color(.systemBackground)) | ||
ProgressView().tint(Color.page) | ||
} else { | ||
Text("Sign in").font(.body.weight(.semibold)) | ||
} | ||
} | ||
.foregroundStyle(ready || busy ? Color(.systemBackground) : Color.secondary) | ||
.foregroundStyle(ready || busy ? Color.page : Color.mute) | ||
.frame(maxWidth: .infinity, minHeight: Size.signIn) | ||
.background(ready || busy ? Color.primary : Color(.tertiarySystemFill), in: Capsule()) | ||
.background(ready || busy ? Color.fg : Color.fill, in: Capsule()) | ||
.contentShape(Capsule()) | ||
} | ||
.buttonStyle(.plain) | ||
| @@ -340,7 +340,7 @@ struct SignInForm: View { | ||
} | ||
.scrollBounceBehavior(.basedOnSize) | ||
.frame(maxHeight: Size.row * CGFloat(found.count)) | ||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: Radius.card)) | ||
.background(Color.raised, in: RoundedRectangle(cornerRadius: Radius.card)) | ||
.clipShape(RoundedRectangle(cornerRadius: Radius.card)) | ||
// It takes the room it asks for before the space above it does. | ||
.layoutPriority(1) | ||
ios/Knotless/API/AppUpdate.swift+2−2
| @@ -62,9 +62,9 @@ struct UpdateScreen: View { | ||
Button { openURL(store) } label: { | ||
Text("Update") | ||
.font(.body.weight(.semibold)) | ||
.foregroundStyle(Color(.systemBackground)) | ||
.foregroundStyle(Color.page) | ||
.frame(maxWidth: .infinity, minHeight: Size.signIn) | ||
.background(Color.primary, in: Capsule()) | ||
.background(Color.fg, in: Capsule()) | ||
.contentShape(Capsule()) | ||
} | ||
.buttonStyle(.plain) | ||
ios/Knotless/API/Client.swift+20−1
| @@ -59,14 +59,20 @@ actor Client { | ||
} | ||
| ||
func query<T: Decodable & Sendable>(_ method: String, _ params: [String: String?] = [:]) async throws -> T { | ||
try await query(method, items: params.compactMap { k, v in v.map { URLQueryItem(name: k, value: $0) } }) | ||
} | ||
| ||
/// As above, with parameters that may repeat, as an array's items do. | ||
func query<T: Decodable & Sendable>(_ method: String, items: [URLQueryItem]) async throws -> T { | ||
let nsid = "dev.knotless." + method | ||
#if DEBUG | ||
if let recorded = DevAccount.recording { | ||
let params = Dictionary(items.map { ($0.name, $0.value) }) { _, last in last } | ||
return try decoder.decode(T.self, from: recorded.answer(method, params)) | ||
} | ||
#endif | ||
var parts = URLComponents(url: Self.site.appendingPathComponent("xrpc/" + nsid), resolvingAgainstBaseURL: false)! | ||
parts.queryItems = params.compactMap { k, v in v.map { URLQueryItem(name: k, value: $0) } }.sorted { $0.name < $1.name } | ||
parts.queryItems = items.sorted { $0.name < $1.name } | ||
var request = URLRequest(url: parts.url!) | ||
request.timeoutInterval = 40 | ||
authorize(&request, nsid) | ||
| @@ -193,6 +199,17 @@ enum DevAccount { | ||
/// The time to read others against, so a picture reads the same on any day. | ||
static let now: Date? = env["KNOTLESS_NOW"].flatMap { ISO8601DateFormatter().date(from: $0) } | ||
static let scheme: ColorScheme? = ["light": ColorScheme.light, "dark": .dark][env["KNOTLESS_SCHEME"] ?? ""] | ||
/// A theme to draw the app in, by the name the pages give it: `dracula`, or `latte/mocha` for a pair. | ||
/// Forgets how the app was last left drawn, for `ios/look.mjs`. | ||
static func freshen() { | ||
guard env["KNOTLESS_FRESH"] != nil else { return } | ||
for key in [Appearance.key, "account-theme", "account-themes"] { UserDefaults.standard.removeObject(forKey: key) } | ||
} | ||
| ||
static let theme: ThemeChoice? = env["KNOTLESS_THEME"].map { name in | ||
let halves = name.split(separator: "/").map { ThemeHalf(theme: String($0)) } | ||
return ThemeChoice(day: halves[0], night: halves.count > 1 ? halves[1] : nil) | ||
} | ||
/// Answers read from a file rather than a server, for `ios/look.mjs`. | ||
static let recording: Recording? = env["KNOTLESS_ANSWERS"].flatMap { Recording(path: $0, misses: env["KNOTLESS_MISSES"]) } | ||
| ||
| @@ -210,6 +227,8 @@ enum DevAccount { | ||
static let signedIn = false | ||
static let now: Date? = nil | ||
static let scheme: ColorScheme? = nil | ||
static let theme: ThemeChoice? = nil | ||
static func freshen() {} | ||
static func token(for nsid: String) -> String? { nil } | ||
#endif | ||
} | ||
ios/Knotless/API/Models.swift+79−2
| @@ -764,17 +764,92 @@ struct Feed: Decodable { | ||
var cursor: String? | ||
} | ||
| ||
/// A `dev.knotless.theme#palette` as the account keeps it. | ||
struct PaletteRecord: Codable, Equatable, Sendable { | ||
struct Syntax: Codable, Equatable, Sendable { | ||
var comments, strings, keywords, numbers, functions, types, tags: String? | ||
} | ||
| ||
var colorScheme: String? | ||
var background, panel, soft, border, text, textMuted, textFaint: String? | ||
var primary, onPrimary, accent, success, danger, done, attention, neutral: String? | ||
var syntax: Syntax? | ||
} | ||
| ||
/// A theme the app is drawn in, as `appearancePref` and its `night` name one: | ||
/// one the pages offer by name, or `custom` with its palette. | ||
struct ThemeHalf: Codable, Equatable, Sendable { | ||
var theme: String? | ||
var palette: PaletteRecord? | ||
} | ||
| ||
/// How the app is drawn: in one theme, or in a day one while the system is | ||
/// light and a night one while it is dark. | ||
struct ThemeChoice: Codable, Equatable, Sendable { | ||
var day: ThemeHalf | ||
var night: ThemeHalf? | ||
| ||
init(day: ThemeHalf, night: ThemeHalf? = nil) { | ||
self.day = day | ||
self.night = night | ||
} | ||
} | ||
| ||
/// A theme of the account's own: one it published, or one it added, as Appearance | ||
/// on the pages lists them. | ||
struct OwnTheme: Codable, Equatable, Identifiable, Sendable { | ||
let uri: String | ||
let name: String | ||
let palette: PaletteRecord | ||
/// Who made it, when that's someone else. | ||
var maker: String? | ||
/// When it was made or added, which orders them, the latest first. | ||
var at: String? | ||
| ||
var id: String { uri } | ||
} | ||
| ||
/// What `getPreferences` answers, of the kinds the app acts on. | ||
struct Preferences: Decodable, Sendable { | ||
let notifications: NotificationChoices? | ||
/// The theme the pages are drawn in, which the app is drawn in too. | ||
let appearance: ThemeChoice? | ||
/// The themes people made that the account added to its own, `themesPref`. | ||
let themes: [OwnTheme] | ||
| ||
private struct Added: Decodable { | ||
let uri: String | ||
let name: String? | ||
let palette: PaletteRecord | ||
let addedAt: String? | ||
} | ||
| ||
private struct Kind: Decodable { | ||
let choices: NotificationChoices? | ||
private enum Keys: String, CodingKey { case type = "$type" } | ||
let appearance: ThemeChoice? | ||
let themes: [OwnTheme]? | ||
private enum Keys: String, CodingKey { case type = "$type", theme, palette, night, themes } | ||
| ||
init(from decoder: Decoder) throws { | ||
let type = try decoder.container(keyedBy: Keys.self).decodeIfPresent(String.self, forKey: .type) | ||
let c = try decoder.container(keyedBy: Keys.self) | ||
let type = try c.decodeIfPresent(String.self, forKey: .type) | ||
choices = try type == NotificationChoices.type ? NotificationChoices(from: decoder) : nil | ||
if type == "dev.knotless.getPreferences#appearancePref" { | ||
let day = ThemeHalf( | ||
theme: try? c.decodeIfPresent(String.self, forKey: .theme), | ||
palette: try? c.decodeIfPresent(PaletteRecord.self, forKey: .palette) | ||
) | ||
let night = try? c.decodeIfPresent(ThemeHalf.self, forKey: .night) | ||
appearance = ThemeChoice(day: day, night: night) | ||
} else { | ||
appearance = nil | ||
} | ||
if type == "dev.knotless.getPreferences#themesPref" { | ||
let added = (try? c.decode([Added].self, forKey: .themes)) ?? [] | ||
themes = added.map { OwnTheme(uri: $0.uri, name: $0.name ?? "Untitled", palette: $0.palette, at: $0.addedAt) } | ||
} else { | ||
themes = nil | ||
} | ||
} | ||
} | ||
| ||
| @@ -783,6 +858,8 @@ struct Preferences: Decodable, Sendable { | ||
init(from decoder: Decoder) throws { | ||
let kinds = try decoder.container(keyedBy: Keys.self).decode([Kind].self, forKey: .preferences) | ||
notifications = kinds.lazy.compactMap(\.choices).first | ||
appearance = kinds.lazy.compactMap(\.appearance).first | ||
themes = kinds.lazy.compactMap(\.themes).first ?? [] | ||
} | ||
} | ||
| ||
ios/Knotless/Support/Diagram.swift+12−9
| @@ -141,6 +141,8 @@ actor Diagrams { | ||
/// An SVG Pele made, in a web view with no script, no store and no way off the page. | ||
private struct DiagramPage: UIViewRepresentable { | ||
let svg: String | ||
/// Read, so a change of theme draws it again, in the theme's colours. | ||
@Environment(\.themeGeneration) private var generation | ||
| ||
func makeCoordinator() -> Coordinator { Coordinator() } | ||
| ||
| @@ -160,8 +162,9 @@ private struct DiagramPage: UIViewRepresentable { | ||
} | ||
| ||
func updateUIView(_ view: WKWebView, context: Context) { | ||
guard context.coordinator.shown != svg else { return } | ||
context.coordinator.shown = svg | ||
let shown = "\(generation) \(svg)" | ||
guard context.coordinator.shown != shown else { return } | ||
context.coordinator.shown = shown | ||
view.loadHTMLString(Self.page(svg), baseURL: nil) | ||
} | ||
| ||
| @@ -195,14 +198,14 @@ private struct DiagramPage: UIViewRepresentable { | ||
return "rgba(\(Int((r * 255).rounded())),\(Int((g * 255).rounded())),\(Int((b * 255).rounded())),\(a))" | ||
} | ||
return [ | ||
("bg", UIColor.secondarySystemGroupedBackground), | ||
("fg", .label), | ||
("muted", .secondaryLabel), | ||
("line", .systemGray), | ||
("bg", UIColor(Color.card)), | ||
("fg", UIColor(Color.fg)), | ||
("muted", UIColor(Color.mute)), | ||
("line", UIColor(Color(themed: \.mute, otherwise: .systemGray))), | ||
// Fills a step off the card behind, in dark as in light. | ||
("surface", .tertiarySystemGroupedBackground), | ||
("surface-alt", UIColor(Color(light: 0xe5e5ea, dark: 0x3a3a3c))), | ||
("border", .systemGray2), | ||
("surface", UIColor(Color.inset)), | ||
("surface-alt", UIColor(Color(themed: { Palette.mix($0.fg, 0.14, $0.bg) }, light: 0xe5e5ea, dark: 0x3a3a3c))), | ||
("border", UIColor(Color(themed: \.line, otherwise: .systemGray2))), | ||
("accent", UIColor(Color.accent)), | ||
("critical", UIColor(Color.danger)), | ||
("error", UIColor(Color.danger)), | ||
ios/Knotless/Support/Highlight.swift+8−8
| @@ -5,14 +5,14 @@ import SwiftUI | ||
/// newlines, in UTF-16 units as the pages read them (`web/src/lib/highlight.ts`). | ||
enum Highlight { | ||
private static let colors: [String: Color] = [ | ||
"comment": Color(light: 0x59636e, dark: 0x9198a1), | ||
"string": Color(light: 0x0a3069, dark: 0xa5d6ff), | ||
"keyword": Color(light: 0xcf222e, dark: 0xff7b72), | ||
"number": Color(light: 0x0550ae, dark: 0x79c0ff), | ||
"function": Color(light: 0x6639ba, dark: 0xd2a8ff), | ||
"type": Color(light: 0x953800, dark: 0xffa657), | ||
"tag": Color(light: 0x116329, dark: 0x7ee787), | ||
"attr": Color(light: 0x0550ae, dark: 0x79c0ff), | ||
"comment": Color(themed: { $0.syntax["comment"] ?? 0 }, light: 0x59636e, dark: 0x9198a1), | ||
"string": Color(themed: { $0.syntax["string"] ?? 0 }, light: 0x0a3069, dark: 0xa5d6ff), | ||
"keyword": Color(themed: { $0.syntax["keyword"] ?? 0 }, light: 0xcf222e, dark: 0xff7b72), | ||
"number": Color(themed: { $0.syntax["number"] ?? 0 }, light: 0x0550ae, dark: 0x79c0ff), | ||
"function": Color(themed: { $0.syntax["function"] ?? 0 }, light: 0x6639ba, dark: 0xd2a8ff), | ||
"type": Color(themed: { $0.syntax["type"] ?? 0 }, light: 0x953800, dark: 0xffa657), | ||
"tag": Color(themed: { $0.syntax["tag"] ?? 0 }, light: 0x116329, dark: 0x7ee787), | ||
"attr": Color(themed: { $0.syntax["attr"] ?? 0 }, light: 0x0550ae, dark: 0x79c0ff), | ||
] | ||
| ||
static func paint(lines: [String], ranges: String?) -> [AttributedString] { | ||
ios/Knotless/Support/Markdown.swift+6−6
| @@ -84,7 +84,7 @@ struct MarkdownView: View { | ||
box(number: number, done: done, about: text) | ||
rich(text) | ||
.strikethrough(done) | ||
.foregroundStyle(done ? Color.secondary : Color.primary) | ||
.foregroundStyle(done ? Color.mute : Color.fg) | ||
} | ||
.padding(.leading, CGFloat(depth) * 16) | ||
} | ||
| @@ -94,13 +94,13 @@ struct MarkdownView: View { | ||
ScrollView(.horizontal, showsIndicators: false) { | ||
Text(Self.painted(text, colours[text])).font(.footnote.monospaced()).textSelection(.enabled).padding(Space.s2_5) | ||
} | ||
.background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: Radius.medium)) | ||
.background(Color.fill, in: RoundedRectangle(cornerRadius: Radius.medium)) | ||
} | ||
| ||
private func box(number: Int, done: Bool, about text: String) -> some View { | ||
Button { tick?(number, !done) } label: { | ||
Image(systemName: done ? "checkmark.square.fill" : "square") | ||
.foregroundStyle(tick == nil ? Color.secondary : done ? Color.accent : Color.secondary) | ||
.foregroundStyle(tick == nil ? Color.mute : done ? Color.accent : Color.mute) | ||
// A finger's room round it, which moves nothing around it. | ||
.padding(Space.s2) | ||
.contentShape(Rectangle()) | ||
| @@ -122,7 +122,7 @@ struct MarkdownView: View { | ||
GridRow { | ||
ForEach(Array(table.header.enumerated()), id: \.offset) { at, cell in | ||
// On one line, so that each is as tall and the fill runs along the row. | ||
self.cell(cell, table.alignments[at], wraps: false).fontWeight(.semibold).background(Color(.tertiarySystemFill)) | ||
self.cell(cell, table.alignments[at], wraps: false).fontWeight(.semibold).background(Color.fill) | ||
} | ||
} | ||
ForEach(Array(table.rows.enumerated()), id: \.offset) { _, row in | ||
| @@ -134,7 +134,7 @@ struct MarkdownView: View { | ||
} | ||
} | ||
} | ||
.overlay(RoundedRectangle(cornerRadius: Radius.medium).stroke(Color(.separator))) | ||
.overlay(RoundedRectangle(cornerRadius: Radius.medium).stroke(Color.line)) | ||
.clipShape(RoundedRectangle(cornerRadius: Radius.medium)) | ||
// Room for its border, which the scroll view would cut. | ||
.padding(Space.s0_5) | ||
| @@ -246,7 +246,7 @@ struct MarkdownView: View { | ||
if var marked = try? AttributedString(markdown: references.linked(text), options: options) { | ||
for run in marked.runs where run.inlinePresentationIntent?.contains(.code) == true { | ||
marked[run.range].font = .callout.monospaced() | ||
marked[run.range].backgroundColor = Color(.secondarySystemBackground) | ||
marked[run.range].backgroundColor = Color.raised | ||
} | ||
return Text(marked) | ||
} | ||
ios/Knotless/Support/Palette.swiftnew+251−0
| @@ -0,0 +1,251 @@ | ||
import SwiftUI | ||
import UIKit | ||
| ||
/// The colours a theme draws the app in: a `dev.knotless.theme#palette`, each | ||
/// named for what it colours, with what it leaves out filled in as the pages do | ||
/// (`web/src/tokens.css`). | ||
struct Palette: Equatable, Sendable { | ||
var dark: Bool | ||
var bg: UInt32 | ||
var panel: UInt32 | ||
var soft: UInt32 | ||
var line: UInt32 | ||
var fg: UInt32 | ||
var mute: UInt32 | ||
var faint: UInt32 | ||
var primary: UInt32 | ||
var onPrimary: UInt32 | ||
var accent: UInt32 | ||
var success: UInt32 | ||
var danger: UInt32 | ||
var done: UInt32 | ||
var attention: UInt32 | ||
var neutral: UInt32 | ||
/// Code's colours, by the names `Highlight` paints: comment, string, keyword… | ||
var syntax: [String: UInt32] | ||
| ||
var scheme: ColorScheme { dark ? .dark : .light } | ||
} | ||
| ||
extension Palette { | ||
/// Light, as the pages draw it: what a light theme of someone's own falls back on. | ||
static let light = Palette( | ||
dark: false, bg: 0xffffff, panel: 0xfafafa, soft: 0xf4f4f4, line: 0xe8e8e8, | ||
fg: 0x171717, mute: 0x525252, faint: 0x6e6e6e, primary: 0x2563eb, onPrimary: 0xffffff, | ||
accent: 0x2f5fb3, success: 0x1a7f37, danger: 0xcf222e, done: 0x7048b5, attention: 0x9a6700, neutral: 0x636363, | ||
syntax: ["comment": 0x59636e, "string": 0x0a3069, "keyword": 0xcf222e, "number": 0x0550ae, | ||
"function": 0x6639ba, "type": 0x953800, "tag": 0x116329, "attr": 0x0550ae] | ||
) | ||
| ||
/// Dark, as the pages draw it. | ||
static let dark = Palette( | ||
dark: true, bg: 0x111111, panel: 0x161616, soft: 0x1a1a1a, line: 0x262626, | ||
fg: 0xededed, mute: 0xa1a1a1, faint: 0x848484, primary: 0x2563eb, onPrimary: 0xffffff, | ||
accent: 0x7aa7f0, success: 0x3fb950, danger: 0xf85149, done: 0xb39af0, attention: 0xd29922, neutral: 0x9198a1, | ||
syntax: ["comment": 0x9198a1, "string": 0xa5d6ff, "keyword": 0xff7b72, "number": 0x79c0ff, | ||
"function": 0xd2a8ff, "type": 0xffa657, "tag": 0x7ee787, "attr": 0x79c0ff] | ||
) | ||
| ||
/// The themes the pages offer besides light and dark, by the name | ||
/// `appearancePref` gives each, as `web/src/tokens.css` draws them. | ||
static let named: [String: Palette] = [ | ||
"latte": Palette( | ||
dark: false, bg: 0xeff1f5, panel: 0xe6e9ef, soft: 0xdce0e8, line: 0xccd0da, | ||
fg: 0x3c3f58, mute: 0x53566c, faint: 0x60626e, primary: 0x8839ef, onPrimary: 0xffffff, | ||
accent: 0x1955cc, success: 0x317c21, danger: 0xd20f39, done: 0x8839ef, attention: 0x996214, neutral: 0x696c81, | ||
syntax: ["comment": 0x5d5f6b, "string": 0x2d701e, "keyword": 0x7b33d7, "number": 0xa64108, | ||
"function": 0x1a58d5, "type": 0x865511, "tag": 0xc40e35, "attr": 0xa64108] | ||
), | ||
"mocha": Palette( | ||
dark: true, bg: 0x1e1e2e, panel: 0x24243a, soft: 0x313244, line: 0x363749, | ||
fg: 0xcdd6f4, mute: 0xa6adc8, faint: 0x989caf, primary: 0xcba6f7, onPrimary: 0x1e1e2e, | ||
accent: 0x89b4fa, success: 0xa6e3a1, danger: 0xf38ba8, done: 0xcba6f7, attention: 0xf9e2af, neutral: 0x9399b2, | ||
syntax: ["comment": 0x9a9eb1, "string": 0xa6e3a1, "keyword": 0xcba6f7, "number": 0xfab387, | ||
"function": 0x89b4fa, "type": 0xf9e2af, "tag": 0xf38ba8, "attr": 0xfab387] | ||
), | ||
"dracula": Palette( | ||
dark: true, bg: 0x282a36, panel: 0x2d2f3d, soft: 0x363849, line: 0x3c3f52, | ||
fg: 0xf8f8f2, mute: 0xb9bcd6, faint: 0x98a1c4, primary: 0xbd93f9, onPrimary: 0x282a36, | ||
accent: 0xff79c6, success: 0x50fa7b, danger: 0xff5555, done: 0xbd93f9, attention: 0xf1fa8c, neutral: 0xa9adc9, | ||
syntax: ["comment": 0xa1aac9, "string": 0xf1fa8c, "keyword": 0xff92d1, "number": 0xc9a7fa, | ||
"function": 0x50fa7b, "type": 0x8be9fd, "tag": 0x50fa7b, "attr": 0x50fa7b] | ||
), | ||
"pckt": Palette( | ||
dark: true, bg: 0x101828, panel: 0x172133, soft: 0x1e2939, line: 0x2b3648, | ||
fg: 0xf9fafb, mute: 0xb4bac5, faint: 0x8a92a0, primary: 0x58a7ff, onPrimary: 0x101828, | ||
accent: 0x58a7ff, success: 0x21dc66, danger: 0xff5d5b, done: 0xb39dfa, attention: 0xffca46, neutral: 0x99a1af, | ||
syntax: ["comment": 0x8a92a0, "string": 0x21dc66, "keyword": 0xff5d5b, "number": 0xffca46, | ||
"function": 0x58a7ff, "type": 0xffca46, "tag": 0xff5d5b, "attr": 0x58a7ff] | ||
), | ||
"plyr": Palette( | ||
dark: true, bg: 0x0a0a0a, panel: 0x141414, soft: 0x1a1a1a, line: 0x282828, | ||
fg: 0xe8e8e8, mute: 0xb0b0b0, faint: 0x848484, primary: 0x6a9fff, onPrimary: 0x0a0a0a, | ||
accent: 0x6a9fff, success: 0x4ade80, danger: 0xef4444, done: 0xa78bfa, attention: 0xfbbf24, neutral: 0x808080, | ||
syntax: ["comment": 0x888888, "string": 0x4ade80, "keyword": 0x6a9fff, "number": 0xfbbf24, | ||
"function": 0x8ab3ff, "type": 0xa78bfa, "tag": 0xf16060, "attr": 0xfbbf24] | ||
), | ||
"blacksky": Palette( | ||
dark: true, bg: 0x070c0c, panel: 0x0e1212, soft: 0x1f2020, line: 0x262828, | ||
fg: 0xf8faf9, mute: 0xc8cac9, faint: 0x9c9e9e, primary: 0x8686ff, onPrimary: 0x070c0c, | ||
accent: 0x8686ff, success: 0xd2fc51, danger: 0xff4b78, done: 0xb0b3f0, attention: 0xffd166, neutral: 0x9c9e9e, | ||
syntax: ["comment": 0x9c9e9e, "string": 0xd2fc51, "keyword": 0x8686ff, "number": 0xff7396, | ||
"function": 0xb0b3f0, "type": 0xaefa59, "tag": 0xff7396, "attr": 0xb0b3f0] | ||
), | ||
] | ||
| ||
/// A palette of someone's own: light's or dark's colours, as its scheme says, | ||
/// under its own; the surfaces and texts it leaves out mixed from its | ||
/// background and text, as `[data-theme^='custom-']` mixes them. | ||
init(record: PaletteRecord) { | ||
let base: Palette = record.colorScheme == "dark" ? .dark : .light | ||
let bg = Self.hex(record.background) ?? base.bg | ||
let fg = Self.hex(record.text) ?? base.fg | ||
let mixed = { (part: Double) in Self.mix(fg, part, bg) } | ||
self.init( | ||
dark: base.dark, bg: bg, | ||
panel: Self.hex(record.panel) ?? mixed(0.04), | ||
soft: Self.hex(record.soft) ?? mixed(0.08), | ||
line: Self.hex(record.border) ?? mixed(0.18), | ||
fg: fg, | ||
mute: Self.hex(record.textMuted) ?? mixed(0.80), | ||
faint: Self.hex(record.textFaint) ?? mixed(0.70), | ||
primary: Self.hex(record.primary) ?? base.primary, | ||
onPrimary: Self.hex(record.onPrimary) ?? bg, | ||
accent: Self.hex(record.accent) ?? base.accent, | ||
success: Self.hex(record.success) ?? base.success, | ||
danger: Self.hex(record.danger) ?? base.danger, | ||
done: Self.hex(record.done) ?? base.done, | ||
attention: Self.hex(record.attention) ?? base.attention, | ||
neutral: Self.hex(record.neutral) ?? base.neutral, | ||
syntax: base.syntax | ||
) | ||
let given: [(String, String?)] = [ | ||
("comment", record.syntax?.comments), ("string", record.syntax?.strings), | ||
("keyword", record.syntax?.keywords), ("number", record.syntax?.numbers), | ||
("function", record.syntax?.functions), ("type", record.syntax?.types), ("tag", record.syntax?.tags), | ||
] | ||
for (name, value) in given { if let v = Self.hex(value) { syntax[name] = v } } | ||
syntax["attr"] = syntax["number"] | ||
} | ||
| ||
/// `#rrggbb` as a number; anything else, nil, which readers pass over. | ||
static func hex(_ string: String?) -> UInt32? { | ||
guard var s = string, s.hasPrefix("#") else { return nil } | ||
s.removeFirst() | ||
guard s.count == 6 else { return nil } | ||
return UInt32(s, radix: 16) | ||
} | ||
| ||
/// `part` of `a` over `b`, as CSS's `color-mix(in srgb, a part, b)`. | ||
static func mix(_ a: UInt32, _ part: Double, _ b: UInt32) -> UInt32 { | ||
func channel(_ shift: UInt32) -> UInt32 { | ||
let x = Double(a >> shift & 255), y = Double(b >> shift & 255) | ||
return UInt32((x * part + y * (1 - part)).rounded()) << shift | ||
} | ||
return channel(16) | channel(8) | channel(0) | ||
} | ||
} | ||
| ||
extension ThemeHalf { | ||
/// Its colours: none for light, dark, or one the app doesn't know, which | ||
/// iOS's own colours draw. | ||
var resolved: Palette? { | ||
switch theme { | ||
case "custom": palette.map(Palette.init(record:)) | ||
case let name?: Palette.named[name] | ||
case nil: nil | ||
} | ||
} | ||
| ||
/// The light or dark it holds the app in, if it holds it in one. | ||
var scheme: ColorScheme? { | ||
switch theme { | ||
case "light": .light | ||
case "dark": .dark | ||
default: resolved?.scheme | ||
} | ||
} | ||
} | ||
| ||
extension ThemeChoice { | ||
func palette(dark: Bool) -> Palette? { | ||
(dark ? night ?? day : day).resolved | ||
} | ||
| ||
/// A pair follows the system, which picks its half. | ||
var scheme: ColorScheme? { night == nil ? day.scheme : nil } | ||
} | ||
| ||
/// The theme the app is in now, for colours to read as they are drawn. | ||
/// `generation` changes with it, carried to them as a trait, so each is drawn again. | ||
enum Themes { | ||
nonisolated(unsafe) private(set) static var current: ThemeChoice? | ||
nonisolated(unsafe) private(set) static var generation = 0 | ||
| ||
@MainActor static func set(_ choice: ThemeChoice?) { | ||
guard choice != current else { return } | ||
current = choice | ||
generation += 1 | ||
} | ||
| ||
static func palette(_ traits: UITraitCollection) -> Palette? { | ||
_ = traits[ThemeTrait.self] | ||
return current?.palette(dark: traits.userInterfaceStyle == .dark) | ||
} | ||
} | ||
| ||
/// Which theme a view is drawn in, so a colour drawn again when it changes asks anew. | ||
struct ThemeTrait: UITraitDefinition { | ||
static let defaultValue = 0 | ||
static let affectsColorAppearance = true | ||
} | ||
| ||
private struct ThemeKey: EnvironmentKey { | ||
static let defaultValue = 0 | ||
} | ||
| ||
extension ThemeKey: UITraitBridgedEnvironmentKey { | ||
static func read(from traits: UITraitCollection) -> Int { traits[ThemeTrait.self] } | ||
static func write(to traits: inout any UIMutableTraits, value: Int) { traits[ThemeTrait.self] = value } | ||
} | ||
| ||
extension EnvironmentValues { | ||
var themeGeneration: Int { | ||
get { self[ThemeKey.self] } | ||
set { self[ThemeKey.self] = newValue } | ||
} | ||
} | ||
| ||
extension Color { | ||
/// A colour of the theme, or iOS's own where there is none. | ||
init(themed: @escaping @Sendable (Palette) -> UInt32, otherwise: UIColor) { | ||
self.init(uiColor: UIColor { traits in | ||
Themes.palette(traits).map { UIColor(hex: themed($0)) } ?? otherwise.resolvedColor(with: traits) | ||
}) | ||
} | ||
| ||
/// A colour of the theme, or its light and dark values where there is none. | ||
init(themed: @escaping @Sendable (Palette) -> UInt32, light: UInt32, dark: UInt32) { | ||
self.init(themed: themed, otherwise: UIColor { $0.userInterfaceStyle == .dark ? UIColor(hex: dark) : UIColor(hex: light) }) | ||
} | ||
| ||
// Surfaces, from the page up, each standing for the iOS colour drawn where there is no theme. | ||
/// A screen's own: `systemBackground`. | ||
static let page = Color(themed: \.bg, otherwise: .systemBackground) | ||
/// A screen of cards: `systemGroupedBackground`. | ||
static let groupedPage = Color(themed: \.bg, otherwise: .systemGroupedBackground) | ||
/// A card on a screen of them: `secondarySystemGroupedBackground`. | ||
static let card = Color(themed: \.panel, otherwise: .secondarySystemGroupedBackground) | ||
/// Something set apart on a page, as a field or a file's header: `secondarySystemBackground`. | ||
static let raised = Color(themed: \.soft, otherwise: .secondarySystemBackground) | ||
/// A chip's or a box's fill: `tertiarySystemFill`. | ||
static let fill = Color(themed: \.soft, otherwise: .tertiarySystemFill) | ||
/// Within a card: `tertiarySystemGroupedBackground`. | ||
static let inset = Color(themed: \.soft, otherwise: .tertiarySystemGroupedBackground) | ||
static let line = Color(themed: \.line, otherwise: .separator) | ||
| ||
// Text. | ||
static let fg = Color(themed: \.fg, otherwise: .label) | ||
static let mute = Color(themed: \.mute, otherwise: .secondaryLabel) | ||
static let faint = Color(themed: \.faint, otherwise: .tertiaryLabel) | ||
} | ||
ios/Knotless/Support/Theme.swift+17−9
| @@ -7,15 +7,23 @@ extension Color { | ||
self.init(uiColor: UIColor { $0.userInterfaceStyle == .dark ? UIColor(hex: dark) : UIColor(hex: light) }) | ||
} | ||
| ||
static let success = Color(light: 0x1a7f37, dark: 0x3fb950) | ||
/// Green to fill behind white text, which `success` is too light for in dark mode. | ||
static let successFill = Color(uiColor: UIColor(hex: 0x1a7f37)) | ||
static let danger = Color(light: 0xcf222e, dark: 0xf85149) | ||
static let done = Color(light: 0x7048b5, dark: 0xb39af0) | ||
static let attention = Color(light: 0x9a6700, dark: 0xd29922) | ||
static let neutral = Color(light: 0x636363, dark: 0x9198a1) | ||
static let accent = Color(light: 0x2f5fb3, dark: 0x7aa7f0) | ||
static let accentSoft = Color(light: 0xeaeff7, dark: 0x222935) | ||
static let success = Color(themed: \.success, light: 0x1a7f37, dark: 0x3fb950) | ||
/// Green to fill behind `onSolid` text: light's green in both of iOS's own, | ||
/// as dark's is too light for white on it, and a theme's own green, under its background. | ||
static let successFill = Color(themed: \.success, otherwise: UIColor(hex: 0x1a7f37)) | ||
/// Text on a solid fill of a state's colour, as the pages' `--on-solid`. | ||
static let onSolid = Color(themed: \.bg, otherwise: .white) | ||
/// The main button's fill: a theme's `primary`, as the pages' `.btn.primary`, | ||
/// and green in iOS's own colours, as the app draws what lands or sends. | ||
static let action = Color(themed: \.primary, otherwise: UIColor(hex: 0x1a7f37)) | ||
/// Text on the main button: a theme's `onPrimary`, which its contrast check holds to `primary`. | ||
static let onAction = Color(themed: \.onPrimary, otherwise: .white) | ||
static let danger = Color(themed: \.danger, light: 0xcf222e, dark: 0xf85149) | ||
static let done = Color(themed: \.done, light: 0x7048b5, dark: 0xb39af0) | ||
static let attention = Color(themed: \.attention, light: 0x9a6700, dark: 0xd29922) | ||
static let neutral = Color(themed: \.neutral, light: 0x636363, dark: 0x9198a1) | ||
static let accent = Color(themed: \.accent, light: 0x2f5fb3, dark: 0x7aa7f0) | ||
static let accentSoft = Color(themed: { Palette.mix($0.accent, 0.15, $0.bg) }, light: 0xeaeff7, dark: 0x222935) | ||
| ||
static func hex(_ string: String?) -> Color? { | ||
guard var s = string, s.hasPrefix("#") else { return nil } | ||
ios/Knotless/Views/AppearanceView.swiftnew+216−0
| @@ -0,0 +1,216 @@ | ||
import SwiftUI | ||
| ||
/// How the app is drawn on this phone: as the account's pages are, in iOS's own | ||
/// colours, in a theme the pages offer, or in one of the account's own themes. | ||
struct Appearance: RawRepresentable, Hashable, Identifiable, Sendable { | ||
let rawValue: String | ||
| ||
init(rawValue: String) { self.rawValue = rawValue } | ||
| ||
static let key = "appearance" | ||
| ||
static let account = Appearance(rawValue: "account") | ||
static let automatic = Appearance(rawValue: "automatic") | ||
static let light = Appearance(rawValue: "light") | ||
static let dark = Appearance(rawValue: "dark") | ||
/// iOS's own colours. | ||
static let system: [Appearance] = [.automatic, .light, .dark] | ||
/// The themes the pages offer besides light and dark, by the name they give each. | ||
static let named: [Appearance] = ["latte", "mocha", "dracula", "pckt", "plyr", "blacksky"].map(Appearance.init) | ||
| ||
/// One of the account's own themes, by its `dev.knotless.theme`. | ||
static func theme(_ uri: String) -> Appearance { Appearance(rawValue: "theme:" + uri) } | ||
| ||
var id: String { rawValue } | ||
| ||
var themeURI: String? { | ||
rawValue.hasPrefix("theme:") ? String(rawValue.dropFirst("theme:".count)) : nil | ||
} | ||
| ||
func label(themes: [OwnTheme]) -> String { | ||
if let uri = themeURI { return themes.first { $0.uri == uri }?.name ?? "A theme of yours" } | ||
switch rawValue { | ||
case "account": return "Same as knotless.dev" | ||
case "latte": return "Catppuccin Latte" | ||
case "mocha": return "Catppuccin Mocha" | ||
case "plyr": return "plyr.fm" | ||
case "pckt": return "pckt" | ||
default: return rawValue.capitalized | ||
} | ||
} | ||
| ||
/// What it draws the app in, given the account's own: none follows the system | ||
/// in iOS's colours, as does one of the account's themes it no longer has. | ||
func choice(account: ThemeChoice?, themes: [OwnTheme]) -> ThemeChoice? { | ||
if let uri = themeURI { | ||
return themes.first { $0.uri == uri }.map { ThemeChoice(day: ThemeHalf(theme: "custom", palette: $0.palette)) } | ||
} | ||
switch self { | ||
case .account: return account | ||
case .automatic: return nil | ||
default: return ThemeChoice(day: ThemeHalf(theme: rawValue)) | ||
} | ||
} | ||
| ||
/// Its colours, for its swatch: none for those that follow the system. | ||
func palette(themes: [OwnTheme]) -> Palette? { | ||
if let uri = themeURI { return themes.first { $0.uri == uri }.map { Palette(record: $0.palette) } } | ||
switch self { | ||
case .light: return .light | ||
case .dark: return .dark | ||
default: return Palette.named[rawValue] | ||
} | ||
} | ||
} | ||
| ||
/// `listThemes` with `actor`: the themes the account published. | ||
private struct ThemeList: Decodable, Sendable { | ||
struct Maker: Decodable, Sendable { | ||
let did: String | ||
let handle: String | ||
} | ||
| ||
struct View: Decodable, Sendable { | ||
let uri: String | ||
let creator: Maker? | ||
let name: String | ||
let palette: PaletteRecord | ||
let createdAt: String? | ||
} | ||
| ||
let themes: [View] | ||
} | ||
| ||
/// The theme the account's pages are drawn in, and its own themes, as it last | ||
/// said: kept for the app to start in them. | ||
@MainActor @Observable | ||
final class AccountTheme { | ||
static let shared = AccountTheme() | ||
private static let key = "account-theme" | ||
private static let themesKey = "account-themes" | ||
| ||
private(set) var choice: ThemeChoice? = AccountTheme.kept(AccountTheme.key) | ||
private(set) var themes: [OwnTheme] = AccountTheme.kept(AccountTheme.themesKey) ?? [] | ||
| ||
private static func kept<T: Decodable>(_ key: String) -> T? { | ||
UserDefaults.standard.data(forKey: key).flatMap { try? JSONDecoder().decode(T.self, from: $0) } | ||
} | ||
| ||
private static func keep(_ value: some Encodable, _ key: String) { | ||
UserDefaults.standard.set(try? JSONEncoder().encode(value), forKey: key) | ||
} | ||
| ||
func hold(_ choice: ThemeChoice?, themes: [OwnTheme] = []) { | ||
self.choice = choice | ||
self.themes = themes | ||
Self.keep(choice, Self.key) | ||
Self.keep(themes, Self.themesKey) | ||
} | ||
| ||
func load(as did: String) async { | ||
guard let answer: Preferences = try? await Client.shared.query("getPreferences") else { return } | ||
let made: ThemeList? = try? await Client.shared.query("listThemes", ["actor": did, "limit": "100"]) | ||
let mine = (made?.themes ?? []).map { | ||
OwnTheme(uri: $0.uri, name: $0.name, palette: $0.palette, maker: $0.creator?.did == did ? nil : $0.creator?.handle, at: $0.createdAt) | ||
} | ||
var added = answer.themes.filter { theme in !mine.contains { $0.uri == theme.uri } } | ||
// Who made each, as they're called now: a theme since deleted has none. | ||
if !added.isEmpty, let found: ThemeList = try? await Client.shared.query("listThemes", items: added.map { URLQueryItem(name: "uris", value: $0.uri) }) { | ||
for i in added.indices { added[i].maker = found.themes.first { $0.uri == added[i].uri }?.creator?.handle } | ||
} | ||
let all = (mine + added).sorted { ($0.at ?? "") > ($1.at ?? "") } | ||
hold(answer.appearance, themes: all) | ||
ios/Knotless/Views/AppearanceView.swiftline 122If the | ||
} | ||
} | ||
| ||
/// Chooses how the app is drawn, each theme shown in its colours. | ||
struct AppearanceView: View { | ||
@AppStorage(Appearance.key) private var appearance = Appearance.account | ||
private let store = AccountTheme.shared | ||
| ||
var body: some View { | ||
List { | ||
Group { | ||
Section { | ||
row(.account, swatch: store.choice?.palette(dark: false)) | ||
} footer: { | ||
Text("The theme you chose on knotless.dev, which changes here when you change it there.") | ||
} | ||
Section("iOS") { | ||
ForEach(Appearance.system) { row($0, swatch: $0.palette(themes: [])) } | ||
} | ||
Section("Themes") { | ||
ForEach(Appearance.named) { row($0, swatch: $0.palette(themes: [])) } | ||
} | ||
Section { | ||
ForEach(store.themes) { theme in | ||
let choice = Appearance.theme(theme.uri) | ||
row(choice, swatch: choice.palette(themes: store.themes), maker: theme.maker) | ||
} | ||
} header: { | ||
Text("Your themes") | ||
} footer: { | ||
if store.themes.isEmpty { | ||
Text("Themes you make or add on knotless.dev show here.") | ||
} | ||
} | ||
} | ||
.listRowBackground(Color.card) | ||
} | ||
.navigationTitle("Appearance") | ||
.navigationBarTitleDisplayMode(.inline) | ||
} | ||
| ||
private func row(_ choice: Appearance, swatch: Palette?, maker: String? = nil) -> some View { | ||
Button { appearance = choice } label: { | ||
HStack(spacing: Space.s3) { | ||
Swatch(palette: swatch) | ||
VStack(alignment: .leading, spacing: Space.s0_5) { | ||
Text(choice.label(themes: store.themes)).foregroundStyle(Color.fg) | ||
if let maker { Text("By \(maker)").font(.footnote).foregroundStyle(Color.mute) } | ||
} | ||
Spacer() | ||
if appearance == choice { | ||
Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(Color.accent) | ||
} | ||
} | ||
.contentShape(Rectangle()) | ||
} | ||
.buttonStyle(.plain) | ||
.accessibilityAddTraits(appearance == choice ? .isSelected : []) | ||
} | ||
} | ||
| ||
/// A theme in small: its page, its text, and its main button and links. One that | ||
/// follows the system is split between light and dark. | ||
private struct Swatch: View { | ||
let palette: Palette? | ||
| ||
var body: some View { | ||
Group { | ||
if let palette { | ||
ZStack(alignment: .bottomLeading) { | ||
Color(uiColor: UIColor(hex: palette.bg)) | ||
HStack(spacing: 3) { | ||
dot(palette.fg) | ||
dot(palette.primary) | ||
dot(palette.accent) | ||
} | ||
.padding(5) | ||
} | ||
} else { | ||
HStack(spacing: 0) { | ||
Color(uiColor: UIColor(hex: Palette.light.bg)) | ||
Color(uiColor: UIColor(hex: Palette.dark.bg)) | ||
} | ||
} | ||
} | ||
.frame(width: 44, height: 30) | ||
.clipShape(RoundedRectangle(cornerRadius: Radius.small)) | ||
.overlay(RoundedRectangle(cornerRadius: Radius.small).stroke(Color.line)) | ||
} | ||
| ||
private func dot(_ hex: UInt32) -> some View { | ||
Circle().fill(Color(uiColor: UIColor(hex: hex))).frame(width: 7, height: 7) | ||
} | ||
} | ||