diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index ed6a8e2..8b0555d 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -12,6 +12,8 @@ private let defaultConfig = ServerConfig( struct MarksApp: App { @State private var serverConfig: ServerConfig = ServerConfig.load() ?? defaultConfig + init() { PaperAppearance.apply() } + var body: some Scene { WindowGroup { MainContainer(config: serverConfig) { diff --git a/Marks/Views/Library/LibraryKit.swift b/Marks/Views/Library/LibraryKit.swift index 53d35ba..b5da6e8 100644 --- a/Marks/Views/Library/LibraryKit.swift +++ b/Marks/Views/Library/LibraryKit.swift @@ -83,11 +83,20 @@ enum Paper { } static func dynamic(light: UInt32, dark: UInt32) -> Color { - Color(uiColor: UIColor { traits in - UIColor(rgb: traits.userInterfaceStyle == .dark ? dark : light) - }) + Color(uiColor: dynamicUI(light: light, dark: dark)) } + static func dynamicUI(light: UInt32, dark: UInt32) -> UIColor { + UIColor { traits in + UIColor(rgb: traits.userInterfaceStyle == .dark ? dark : light) + } + } + + /// UIKit equivalents, for the chrome SwiftUI can't reach — chiefly the + /// navigation bar, whose title font has no SwiftUI API at all. + static let uiSheet = dynamicUI(light: 0xF8F5EF, dark: 0x15130F) + static let uiInk = dynamicUI(light: 0x14110C, dark: 0xF1ECE1) + static func luminance(of hex: UInt32) -> Double { let r = Double((hex >> 16) & 0xFF) / 255 let g = Double((hex >> 8) & 0xFF) / 255 @@ -154,6 +163,56 @@ extension View { } } +// MARK: - UIKit chrome + +/// The navigation and tab bars are UIKit underneath, and their fonts have no +/// SwiftUI equivalent — `navigationTitle` will render in the system bold sans +/// whatever you do to the view. Both are configured once at launch so screen +/// titles speak the same serif as the content beneath them. +enum PaperAppearance { + static func apply() { + let bar = UINavigationBarAppearance() + // Transparent, not opaque: the screens already paint the paper ground + // themselves, and an opaque bar config stops iOS 26 laying out the + // large title at all. + bar.configureWithTransparentBackground() + bar.largeTitleTextAttributes = [ + .font: serif(34, weight: .regular), + .foregroundColor: Paper.uiInk, + ] + bar.titleTextAttributes = [ + .font: serif(17, weight: .medium), + .foregroundColor: Paper.uiInk, + ] + UINavigationBar.appearance().standardAppearance = bar + UINavigationBar.appearance().compactAppearance = bar + UINavigationBar.appearance().scrollEdgeAppearance = bar + + let tabs = UITabBarAppearance() + tabs.configureWithDefaultBackground() + let label: [NSAttributedString.Key: Any] = [ + .font: UIFont.monospacedSystemFont(ofSize: 10, weight: .medium) + ] + for item in [tabs.stackedLayoutAppearance, + tabs.inlineLayoutAppearance, + tabs.compactInlineLayoutAppearance] { + item.normal.titleTextAttributes = label + item.selected.titleTextAttributes = label + } + UITabBar.appearance().standardAppearance = tabs + UITabBar.appearance().scrollEdgeAppearance = tabs + } + + /// `withDesign(.serif)` is the only route to the system serif in UIKit, and + /// it returns nil if the design is unavailable — fall back rather than + /// force-unwrap a font. + private static func serif(_ size: CGFloat, weight: UIFont.Weight) -> UIFont { + let base = UIFont.systemFont(ofSize: size, weight: weight) + guard let descriptor = base.fontDescriptor.withDesign(.serif) else { return base } + return UIFont(descriptor: descriptor, size: size) + } +} + // MARK: - Empty state /// The paper equivalent of `ContentUnavailableView`, which can't be restyled —