~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

The iOS app is drawn in knotless's themes, the account's own included

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:ios/themes
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 122
review.knotless.dev

If the listThemes call with actor fails (made is nil) while getPreferences succeeds, mine is empty and hold overwrites the cached list with only the added themes. The account's published themes then disappear from the picker. If one of them was selected, the app falls back to iOS's colours, and that is saved too, so it stays that way offline. When either themes query fails, keep the previous themes rather than replacing them.

}
}
/// 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)
}
}
ios/Knotless/Views/CodeViews.swift+2−2
ios/Knotless/Views/CommitView.swift+36−33
ios/Knotless/Views/ExploreView.swift+6−3
ios/Knotless/Views/FilesView.swift+4−4
ios/Knotless/Views/HomeView.swift+23−17
ios/Knotless/Views/InboxView.swift+6−3
ios/Knotless/Views/MarkdownImage.swift+1−1
ios/Knotless/Views/Moderation.swift+85−79
ios/Knotless/Views/ProfileView.swift+4−4
ios/Knotless/Views/RepoView.swift+43−37
ios/Knotless/Views/RunViews.swift+8−8
ios/Knotless/Views/SearchView.swift+15−12
ios/Knotless/Views/SettingsView.swift+70−79
ios/Knotless/Views/Shared.swift+62−7
ios/Knotless/Views/ThreadView.swift+7−7
ios/Knotless/Views/Writing.swift+11−11
ios/Knotless/App.swift+33−3
ios/KnotlessUITests/LookTests.swift+4−0
ios/look-answers.json+2−1
ios/look.mjs+8−1
ios/README.md+25−3