From cbaae2b1edec62d04acc85fa57afef39f19a5918 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:12:55 -0700 Subject: [PATCH] feat(theme): apply the saved theme before the account loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RootView read the theme from `authState.user`, which is nil until GET /api/user answers. The login screen therefore always used the OS appearance, a cold launch flashed the OS appearance before snapping to the preference, and an offline start lost it entirely. Mirror the theme into UserDefaults and read it with @AppStorage, which resolves synchronously at first render — no flash. The server value is still the authority the moment it arrives; ThemePreference.resolve pins that precedence, and both sides fall back to "system" for nil, empty, or unknown strings. The mirror is written in exactly one place, a didSet on AuthState.user, so every load/update path keeps it in step; logout leaves it alone so the login screen keeps the last choice. It's a display preference, not a credential — the Keychain-only rule is about tokens. Logo.imageset gains a dark appearance built from the brand kit's own dark artwork, so the cream #F4EEE2 backing square no longer floats as a bright block on black. The mark itself is untouched: 2x/3x are icons/ios/ AppIcon-dark-1024.png resampled to the sizes the light slots already use (they are AppIcon-120/180), and 1x is logo/logo-icon-master.png — the transparent master — flattened onto the same dark tile, mirroring how the light 1x is flattened onto its own background. Links rendered iOS system blue in both appearances despite AccentColor being wired, so tint RootView with ILColor.link. A sheet inherits the color-scheme override (a window-level trait) but not the tint, so the app-level deep-link sheet needs its own — measured on the simulator, not assumed. Tests cover parsing, the server-wins precedence, and a mirror surviving a "relaunch" through a fresh store instance. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1 --- InterlinedList.xcodeproj/project.pbxproj | 8 ++ .../Logo.imageset/Contents.json | 33 ++++++ .../Logo.imageset/Logo-Dark.png | Bin 0 -> 14773 bytes .../Logo.imageset/Logo-Dark@2x.png | Bin 0 -> 4923 bytes .../Logo.imageset/Logo-Dark@3x.png | Bin 0 -> 8271 bytes InterlinedList/InterlinedListApp.swift | 5 +- InterlinedList/Services/AuthState.swift | 13 ++- InterlinedList/Services/ThemePreference.swift | 58 +++++++++++ InterlinedList/Views/RootView.swift | 13 ++- .../ServiceTests/ThemePreferenceTests.swift | 98 ++++++++++++++++++ 10 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 InterlinedList/Assets.xcassets/Logo.imageset/Logo-Dark.png create mode 100644 InterlinedList/Assets.xcassets/Logo.imageset/Logo-Dark@2x.png create mode 100644 InterlinedList/Assets.xcassets/Logo.imageset/Logo-Dark@3x.png create mode 100644 InterlinedList/Services/ThemePreference.swift create mode 100644 InterlinedListTests/ServiceTests/ThemePreferenceTests.swift diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index 6883ba6..da2e4a1 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -65,6 +65,7 @@ 6CED0218B01DF6AA0FF9D3BF /* ListWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0470C00F1A0E215224120A1 /* ListWatcher.swift */; }; 6DACF0399E254AAA27286324 /* AIPoweredTemplateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A2C29076D46F83C22850E30 /* AIPoweredTemplateView.swift */; }; 7201B78F48B60376E06DB08E /* ILColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC5914D730B4355F02E89D81 /* ILColor.swift */; }; + AB80F1A1C0DE000000000001 /* ThemePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB80F1A1C0DE000000000002 /* ThemePreference.swift */; }; 73777B938FC2AF9D06CE5AD4 /* APIClientGitHubTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109F961E434AB806655FCFCF /* APIClientGitHubTests.swift */; }; 745483850C410FDE3C23D44F /* ServerLimitsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */; }; 79878E2A806CDB0B1659C7C5 /* MarkdownEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83DB813EA089E4807AF07110 /* MarkdownEditor.swift */; }; @@ -123,6 +124,7 @@ B1C1D1E1F1A10003 /* AppDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10004 /* AppDataStore.swift */; }; 0A87E63E7459CCDD35BFCFC9 /* MuteConfirmation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 137B3E2137C83882B8434673 /* MuteConfirmation.swift */; }; 1F2D51E9719CC8F468BF5107 /* MuteStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA27DCE269D834AE6F1C569D /* MuteStoreTests.swift */; }; + AB80F1A1C0DE000000000003 /* ThemePreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB80F1A1C0DE000000000004 /* ThemePreferenceTests.swift */; }; 9243867C3794133F8962AEAB /* MuteStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324E543C7571CE2C42D600C8 /* MuteStore.swift */; }; B1C1D1E1F1A10005 /* SkeletonBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10006 /* SkeletonBlock.swift */; }; B1C1D1E1F1A10007 /* FeedSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10008 /* FeedSkeletonView.swift */; }; @@ -365,6 +367,7 @@ 137B3E2137C83882B8434673 /* MuteConfirmation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MuteConfirmation.swift; sourceTree = ""; }; 324E543C7571CE2C42D600C8 /* MuteStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MuteStore.swift; sourceTree = ""; }; BA27DCE269D834AE6F1C569D /* MuteStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MuteStoreTests.swift; sourceTree = ""; }; + AB80F1A1C0DE000000000004 /* ThemePreferenceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ThemePreferenceTests.swift; sourceTree = ""; }; A2B2C2D2E2F20044 /* FollowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowState.swift; sourceTree = ""; }; A2B2C2D2E2F20046 /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; A2B2C2D2E2F20048 /* FollowRequestsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRequestsView.swift; sourceTree = ""; }; @@ -437,6 +440,7 @@ F78B096C366108083D174366 /* MarkdownBlockTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MarkdownBlockTests.swift; sourceTree = ""; }; F9CCBC371A0DEF4F2F42A941 /* GitHubMetadata.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GitHubMetadata.swift; sourceTree = ""; }; FC5914D730B4355F02E89D81 /* ILColor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InterlinedList/Services/ILColor.swift; sourceTree = SOURCE_ROOT; }; + AB80F1A1C0DE000000000002 /* ThemePreference.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ThemePreference.swift; sourceTree = ""; }; T1E5T1E5T1E50000 /* InterlinedListTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InterlinedListTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; T1E5T1E5T1E50002 /* InterlinedListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterlinedListTests.swift; sourceTree = ""; }; T1E5T1E5T1E50004 /* MockURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLSession.swift; sourceTree = ""; }; @@ -596,6 +600,7 @@ D0A1D0A1D0A10002 /* OAuthCoordinator.swift */, 1F84433332486544DA15C7BA /* APIClient+Documents.swift */, FC5914D730B4355F02E89D81 /* ILColor.swift */, + AB80F1A1C0DE000000000002 /* ThemePreference.swift */, 8B79D26B822DCA1188DF37E9 /* PushService.swift */, 5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */, 7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */, @@ -805,6 +810,7 @@ 78BDFABB1D991EB98BFEC2AA /* ImageUploadProcessorTests.swift */, D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */, BA27DCE269D834AE6F1C569D /* MuteStoreTests.swift */, + AB80F1A1C0DE000000000004 /* ThemePreferenceTests.swift */, 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */, D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */, 126B48E798CB7064F46382CC /* DocumentSyncConflictTests.swift */, @@ -974,6 +980,7 @@ CE93B45A2285EF8FD995D9FE /* OrganizationsView.swift in Sources */, D0F35ACF98DDE854A8437C7D /* SettingsView.swift in Sources */, 7201B78F48B60376E06DB08E /* ILColor.swift in Sources */, + AB80F1A1C0DE000000000001 /* ThemePreference.swift in Sources */, 47FBAC21F771B2813EAD8D37 /* Moderation.swift in Sources */, C478109CF3BB06C22E121904 /* ReportSheet.swift in Sources */, 1102DD971BF4AC03027058B9 /* BlockedUsersView.swift in Sources */, @@ -1098,6 +1105,7 @@ T1E5T1E5T1E5P029 /* ModerationModelTests.swift in Sources */, 23E11F4CE0298A685F13AA21 /* APIClientGapPhasesTests.swift in Sources */, 1F2D51E9719CC8F468BF5107 /* MuteStoreTests.swift in Sources */, + AB80F1A1C0DE000000000003 /* ThemePreferenceTests.swift in Sources */, 6C10CC420377B0E8AE5C82DB /* GapModelsTests.swift in Sources */, E1F7D081DC89245C81E2047C /* AppDataStoreTests.swift in Sources */, 64F5804ECC25725FD1E58E84 /* APIClientModerationTests.swift in Sources */, diff --git a/InterlinedList/Assets.xcassets/Logo.imageset/Contents.json b/InterlinedList/Assets.xcassets/Logo.imageset/Contents.json index c75069e..525d0fa 100644 --- a/InterlinedList/Assets.xcassets/Logo.imageset/Contents.json +++ b/InterlinedList/Assets.xcassets/Logo.imageset/Contents.json @@ -5,15 +5,48 @@ "idiom" : "universal", "scale" : "1x" }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Logo-Dark.png", + "idiom" : "universal", + "scale" : "1x" + }, { "filename" : "Logo@2x.png", "idiom" : "universal", "scale" : "2x" }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Logo-Dark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, { "filename" : "Logo@3x.png", "idiom" : "universal", "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Logo-Dark@3x.png", + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/InterlinedList/Assets.xcassets/Logo.imageset/Logo-Dark.png b/InterlinedList/Assets.xcassets/Logo.imageset/Logo-Dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a0b8736375f0c7d5b5eeac483f7cf1b47b120a9b GIT binary patch literal 14773 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 a3*;3kJnpgO()#a-^+?-DtLdRb-2VXgpIvDH literal 0 HcmV?d00001 diff --git a/InterlinedList/Assets.xcassets/Logo.imageset/Logo-Dark@3x.png b/InterlinedList/Assets.xcassets/Logo.imageset/Logo-Dark@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..17c63636e239e6fcacda4629df7a5194b51fe63c GIT binary patch literal 8271 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 private let api = APIClient.shared + private let themeStore: ThemePreferenceStore - init() { + init(themeStore: ThemePreferenceStore = ThemePreferenceStore()) { + self.themeStore = themeStore if let token = KeychainService.loadToken() { api.setBearerToken(token) hasToken = true diff --git a/InterlinedList/Services/ThemePreference.swift b/InterlinedList/Services/ThemePreference.swift new file mode 100644 index 0000000..f4516df --- /dev/null +++ b/InterlinedList/Services/ThemePreference.swift @@ -0,0 +1,58 @@ +// +// ThemePreference.swift +// InterlinedList +// + +import Foundation +import SwiftUI + +/// The account's display-theme choice, as the server stores it ("system" / "light" / "dark"). +enum ThemePreference: String, CaseIterable { + case system + case light + case dark + + /// `UserDefaults` key for the mirror of the server value. A display preference, + /// not a credential, so the Keychain-only rule for tokens does not apply here. + static let storageKey = "themePreference" + + /// Anything the server (or the mirror) can't be read as a known theme means + /// "follow the OS" — the same fallback `RootView` applied before. + init(stored value: String?) { + self = ThemePreference(rawValue: value?.lowercased() ?? "") ?? .system + } + + var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } + + /// The account value is authoritative once `GET /api/user` has answered; until + /// then — the login screen, a cold launch, an offline start — the mirror stands in. + static func resolve(serverTheme: String?, mirrored: String?) -> ThemePreference { + if let serverTheme { + return ThemePreference(stored: serverTheme) + } + return ThemePreference(stored: mirrored) + } +} + +/// Writes the theme mirror that `RootView` reads at first render. +struct ThemePreferenceStore { + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + var current: ThemePreference { + ThemePreference(stored: defaults.string(forKey: ThemePreference.storageKey)) + } + + func save(_ theme: String?) { + defaults.set(ThemePreference(stored: theme).rawValue, forKey: ThemePreference.storageKey) + } +} diff --git a/InterlinedList/Views/RootView.swift b/InterlinedList/Views/RootView.swift index 85a5d44..d14e099 100644 --- a/InterlinedList/Views/RootView.swift +++ b/InterlinedList/Views/RootView.swift @@ -7,6 +7,10 @@ import SwiftUI struct RootView: View { @EnvironmentObject var authState: AuthState + /// Read synchronously at first render, so the saved theme is in force before + /// `GET /api/user` answers — no flash of the OS appearance, and it survives + /// an offline cold start. + @AppStorage(ThemePreference.storageKey) private var mirroredTheme = ThemePreference.system.rawValue var body: some View { Group { @@ -17,6 +21,7 @@ struct RootView: View { } } .preferredColorScheme(preferredScheme) + .tint(ILColor.link) // The server resizes every image upload to its own cap regardless of // what the client sends, so read the caps once at launch and let // ImageUploadProcessor size to them. Public route — no token needed, @@ -24,13 +29,7 @@ struct RootView: View { .task { await ServerLimitsStore.shared.refresh() } } - /// Honor the user's saved theme preference ("light" / "dark"); "system" or - /// missing leaves the OS appearance in control. private var preferredScheme: ColorScheme? { - switch authState.user?.theme { - case "light": return .light - case "dark": return .dark - default: return nil - } + ThemePreference.resolve(serverTheme: authState.user?.theme, mirrored: mirroredTheme).colorScheme } } diff --git a/InterlinedListTests/ServiceTests/ThemePreferenceTests.swift b/InterlinedListTests/ServiceTests/ThemePreferenceTests.swift new file mode 100644 index 0000000..b1ff4b7 --- /dev/null +++ b/InterlinedListTests/ServiceTests/ThemePreferenceTests.swift @@ -0,0 +1,98 @@ +import XCTest +import SwiftUI +@testable import InterlinedList + +final class ThemePreferenceTests: XCTestCase { + private var suiteName = "" + private var defaults = UserDefaults.standard + + override func setUpWithError() throws { + try super.setUpWithError() + suiteName = "ThemePreferenceTests.\(UUID().uuidString)" + defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + super.tearDown() + } + + // MARK: - Parsing + + func test_init_stored_knownValues_parse() { + XCTAssertEqual(ThemePreference(stored: "system"), .system) + XCTAssertEqual(ThemePreference(stored: "light"), .light) + XCTAssertEqual(ThemePreference(stored: "dark"), .dark) + } + + func test_init_stored_mixedCase_parses() { + XCTAssertEqual(ThemePreference(stored: "Dark"), .dark) + } + + func test_init_stored_nil_fallsBackToSystem() { + XCTAssertEqual(ThemePreference(stored: nil), .system) + } + + func test_init_stored_unknownValue_fallsBackToSystem() { + XCTAssertEqual(ThemePreference(stored: "sepia"), .system) + XCTAssertEqual(ThemePreference(stored: ""), .system) + } + + func test_colorScheme_mapsEachCase() { + XCTAssertNil(ThemePreference.system.colorScheme) + XCTAssertEqual(ThemePreference.light.colorScheme, ColorScheme.light) + XCTAssertEqual(ThemePreference.dark.colorScheme, ColorScheme.dark) + } + + // MARK: - Resolution (server value vs. mirror) + + func test_resolve_serverThemePresent_winsOverMirror() { + XCTAssertEqual(ThemePreference.resolve(serverTheme: "light", mirrored: "dark"), .light) + } + + func test_resolve_serverThemeMissing_usesMirror() { + // The login screen and every cold launch before GET /api/user answers. + XCTAssertEqual(ThemePreference.resolve(serverTheme: nil, mirrored: "dark"), .dark) + } + + func test_resolve_serverThemeUnknown_fallsBackToSystemNotMirror() { + XCTAssertEqual(ThemePreference.resolve(serverTheme: "chartreuse", mirrored: "dark"), .system) + } + + func test_resolve_bothMissing_isSystem() { + XCTAssertEqual(ThemePreference.resolve(serverTheme: nil, mirrored: nil), .system) + } + + // MARK: - Mirror persistence + + func test_current_nothingStored_isSystem() { + XCTAssertEqual(ThemePreferenceStore(defaults: defaults).current, .system) + } + + func test_save_thenFreshStore_readsStoredValue() throws { + ThemePreferenceStore(defaults: defaults).save("dark") + + let reopened = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + XCTAssertEqual(ThemePreferenceStore(defaults: reopened).current, .dark) + } + + func test_save_unknownValue_normalizesToSystem() { + let store = ThemePreferenceStore(defaults: defaults) + store.save("dark") + store.save("sepia") + XCTAssertEqual(store.current, .system) + } + + func test_save_nil_normalizesToSystem() { + let store = ThemePreferenceStore(defaults: defaults) + store.save("dark") + store.save(nil) + XCTAssertEqual(store.current, .system) + } + + func test_save_writesRawValueUnderSharedKey() { + // RootView reads the same key through @AppStorage, so the raw string matters. + ThemePreferenceStore(defaults: defaults).save("light") + XCTAssertEqual(defaults.string(forKey: ThemePreference.storageKey), "light") + } +}