From 95865f14258150ade31f32e316241b162ec5d351 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Thu, 17 Sep 2026 03:12:45 -0700 Subject: [PATCH] fix(theme): honour the AccentColor asset app-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configuring UINavigationBar's appearance from `App.init()` builds a UIBarButtonItemAppearance before UIApplication has adopted the AccentColor asset. That resolves UIKit's default bar-button styling early and caches system blue as the process tint for the rest of the launch, so the asset was ignored and every control without an explicit tint rendered iOS blue. Moving the configuration to the app delegate's launch callback keeps the teal masthead and lets the accent apply, which reaches surfaces a `.tint` cannot: sheets, alerts and UIKit-hosted chrome. The hierarchy-wide `.tint` workarounds on RootView and the deep-link sheet are removed — the latter also rendered its Cancel button in the masthead colour against the masthead, i.e. invisible. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1 --- CLAUDE.md | 12 ++++ InterlinedList.xcodeproj/project.pbxproj | 4 ++ InterlinedList/InterlinedListApp.swift | 51 +++++++------- InterlinedList/Views/RootView.swift | 1 - .../ServiceTests/AppAccentColorTests.swift | 68 +++++++++++++++++++ 5 files changed, 109 insertions(+), 27 deletions(-) create mode 100644 InterlinedListTests/ServiceTests/AppAccentColorTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 36a2935..f6af477 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,18 @@ xcodebuild test -scheme InterlinedList -destination 'platform=iOS Simulator,id=< ## Gotchas (current) +- **Brand tint comes from the `AccentColor` asset — never add a hierarchy-wide `.tint`.** The asset + (= `ILColor.primary`) becomes the process tint, so it reaches *every* surface, including sheets, + alerts and UIKit-hosted chrome; a `.tint` set inside `RootView` only reaches that view's own + subtree, which is why an un-tinted sheet used to fall back to system blue. A new sheet therefore + needs nothing. `.tint` stays legal for genuinely local colour (swipe-action backgrounds, a + `ProgressView` on a dark overlay). +- **Do not configure `UIAppearance` from `App.init()`.** Building a `UIBarButtonItemAppearance`'s + state appearance that early resolves UIKit's default bar-button styling before UIApplication has + adopted the accent asset, caching system blue as the process tint for the whole launch — the + accent is then silently ignored everywhere. `configureNavigationBarAppearance()` runs from + `AppDelegate.application(_:didFinishLaunchingWithOptions:)` for exactly this reason; + `AppAccentColorTests` guards it. - **Document folders are path-scoped, not query/body-scoped.** `GET`/`POST /api/documents` are root-only (GET ignores `?folderId`; POST has no `folderId`). Folder contents = `GET /api/documents/folders/{id}/documents`; create-in-folder = `POST .../folders/{id}/documents`; only `PATCH /api/documents/{id}` takes `folderId` to move. Wrong route silently drops the doc to root. - **GitHub-backed lists are editable via the standard `/api/lists/:id/data` routes** — they proxy to GitHub Issues (POST→create, PUT→patch, DELETE→close; a row's `id` **is** the issue number). Updates must send the **FULL row**: the backend rebuilds the issue and defaults a missing required `title` to `"Untitled"`, so a partial `PUT` renames the issue. Use `updateItem` (full row), not `updateRow`; see `ListDetailView.setGitHubState`. - **GitHub schema + response shapes:** the server returns the synthetic schema in `GET /api/lists/:id` `properties` (`isReadOnly`, `state` as a `select`); `ListPropertyDef.gitHubIssueSchema()` is a client fallback when it's empty. Row-mutation responses return the saved row under **`data`**, not `row`. Rows headline via `ListPropertyDef.primaryDisplayField(from:)`, not `schema.first`. diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index 39f5a43..d8e2069 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -140,6 +140,7 @@ FEED105A0000000000000003 /* FeedMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED105A0000000000000004 /* FeedMergeTests.swift */; }; B1C1D1E1F1A10003 /* AppDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10004 /* AppDataStore.swift */; }; AB80F1A1C0DE000000000003 /* ThemePreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB80F1A1C0DE000000000004 /* ThemePreferenceTests.swift */; }; + AC102ACC0000000000000001 /* AppAccentColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC102ACC0000000000000002 /* AppAccentColorTests.swift */; }; B1C1D1E1F1A10005 /* SkeletonBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10006 /* SkeletonBlock.swift */; }; B1C1D1E1F1A10007 /* FeedSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10008 /* FeedSkeletonView.swift */; }; B1C1D1E1F1A10009 /* ListSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A1000A /* ListSkeletonView.swift */; }; @@ -404,6 +405,7 @@ A2B2C2D2E2F20042 /* AppNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotification.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 = ""; }; + AC102ACC0000000000000002 /* AppAccentColorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppAccentColorTests.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 = ""; }; @@ -872,6 +874,7 @@ D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */, BA27DCE269D834AE6F1C569D /* MuteStoreTests.swift */, AB80F1A1C0DE000000000004 /* ThemePreferenceTests.swift */, + AC102ACC0000000000000002 /* AppAccentColorTests.swift */, 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */, FEED105A0000000000000004 /* FeedMergeTests.swift */, D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */, @@ -1184,6 +1187,7 @@ 23E11F4CE0298A685F13AA21 /* APIClientGapPhasesTests.swift in Sources */, 1F2D51E9719CC8F468BF5107 /* MuteStoreTests.swift in Sources */, AB80F1A1C0DE000000000003 /* ThemePreferenceTests.swift in Sources */, + AC102ACC0000000000000001 /* AppAccentColorTests.swift in Sources */, 6C10CC420377B0E8AE5C82DB /* GapModelsTests.swift in Sources */, E1F7D081DC89245C81E2047C /* AppDataStoreTests.swift in Sources */, 64F5804ECC25725FD1E58E84 /* APIClientModerationTests.swift in Sources */, diff --git a/InterlinedList/InterlinedListApp.swift b/InterlinedList/InterlinedListApp.swift index a16f429..b0812d5 100644 --- a/InterlinedList/InterlinedListApp.swift +++ b/InterlinedList/InterlinedListApp.swift @@ -16,10 +16,6 @@ struct InterlinedListApp: App { @StateObject private var settingsSync = AppSettingsSyncService() @Environment(\.scenePhase) private var scenePhase - init() { - configureNavigationBarAppearance() - } - var body: some Scene { WindowGroup { RootView() @@ -52,10 +48,7 @@ struct InterlinedListApp: App { } } .sheet(item: $router.pendingDeepLink) { link in - // A sheet inherits the color-scheme override from RootView (it is a - // window-level trait) but not its tint, so brand the sheet's own - // controls here or they render iOS system blue. - deepLinkSheet(for: link).tint(ILColor.link) + deepLinkSheet(for: link) } } } @@ -174,24 +167,6 @@ struct InterlinedListApp: App { // Same rationale as verifyEmail above. } } - - private func configureNavigationBarAppearance() { - let teal = UIColor { $0.userInterfaceStyle == .dark - ? UIColor(red: 0.047, green: 0.173, blue: 0.227, alpha: 1) - : UIColor(red: 0.094, green: 0.282, blue: 0.376, alpha: 1) } - let appearance = UINavigationBarAppearance() - appearance.configureWithOpaqueBackground() - appearance.backgroundColor = teal - appearance.titleTextAttributes = [.foregroundColor: UIColor.white] - appearance.largeTitleTextAttributes = [.foregroundColor: UIColor.white] - let back = UIBarButtonItemAppearance() - back.normal.titleTextAttributes = [.foregroundColor: UIColor.white] - appearance.backButtonAppearance = back - UINavigationBar.appearance().standardAppearance = appearance - UINavigationBar.appearance().scrollEdgeAppearance = appearance - UINavigationBar.appearance().compactAppearance = appearance - UINavigationBar.appearance().tintColor = .white - } } enum AppDeepLink: Identifiable, Hashable { @@ -322,9 +297,33 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { UNUserNotificationCenter.current().delegate = self + configureNavigationBarAppearance() return true } + /// Must not run from `App.init()`. Building the back-button appearance there + /// resolves UIKit's default bar-button styling before UIApplication has adopted + /// the `AccentColor` asset, which caches system blue as the process tint for the + /// rest of the launch — so every control that isn't explicitly tinted renders + /// iOS blue instead of brand green (#102). + private func configureNavigationBarAppearance() { + let teal = UIColor { $0.userInterfaceStyle == .dark + ? UIColor(red: 0.047, green: 0.173, blue: 0.227, alpha: 1) + : UIColor(red: 0.094, green: 0.282, blue: 0.376, alpha: 1) } + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = teal + appearance.titleTextAttributes = [.foregroundColor: UIColor.white] + appearance.largeTitleTextAttributes = [.foregroundColor: UIColor.white] + let back = UIBarButtonItemAppearance() + back.normal.titleTextAttributes = [.foregroundColor: UIColor.white] + appearance.backButtonAppearance = back + UINavigationBar.appearance().standardAppearance = appearance + UINavigationBar.appearance().scrollEdgeAppearance = appearance + UINavigationBar.appearance().compactAppearance = appearance + UINavigationBar.appearance().tintColor = .white + } + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { Task { @MainActor in PushService.shared.didRegister(deviceToken: deviceToken) diff --git a/InterlinedList/Views/RootView.swift b/InterlinedList/Views/RootView.swift index d14e099..140f207 100644 --- a/InterlinedList/Views/RootView.swift +++ b/InterlinedList/Views/RootView.swift @@ -21,7 +21,6 @@ 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, diff --git a/InterlinedListTests/ServiceTests/AppAccentColorTests.swift b/InterlinedListTests/ServiceTests/AppAccentColorTests.swift new file mode 100644 index 0000000..aadbe60 --- /dev/null +++ b/InterlinedListTests/ServiceTests/AppAccentColorTests.swift @@ -0,0 +1,68 @@ +import XCTest +import SwiftUI +import UIKit +@testable import InterlinedList + +/// The app is branded by the `AccentColor` asset rather than by a `.tint` +/// modifier: the asset becomes the process-wide UIKit tint, so it reaches every +/// surface — sheets, alerts and UIKit-hosted chrome included — where a `.tint` +/// set inside `RootView` only reaches that view's own subtree (#102). +final class AppAccentColorTests: XCTestCase { + private let light = UITraitCollection(userInterfaceStyle: .light) + private let dark = UITraitCollection(userInterfaceStyle: .dark) + + func test_infoPlist_declaresTheAccentColorAsset() { + XCTAssertEqual( + Bundle.main.object(forInfoDictionaryKey: "NSAccentColorName") as? String, + "AccentColor" + ) + } + + // `ILColor.primary` is `green` in light and `greenDark` in dark; the two halves + // are compared separately because `UIColor(_: Color)` resolves a dynamic color + // eagerly and loses the appearance provider. + func test_accentAsset_matchesBrandGreen_inLight() throws { + let accent = try XCTUnwrap(UIColor(named: "AccentColor")) + assertSameColor(accent, UIColor(ILColor.green), with: light) + } + + func test_accentAsset_matchesBrandGreenDark_inDark() throws { + let accent = try XCTUnwrap(UIColor(named: "AccentColor")) + assertSameColor(accent, UIColor(ILColor.greenDark), with: dark) + } + + func test_processTint_isTheAccentAsset_notSystemBlue_inLight() throws { + // Touching a UIAppearance proxy from `App.init()` makes UIKit fall back to + // the system-blue tint for the whole process and silently ignore the asset; + // that is why `configureNavigationBarAppearance()` runs from the app + // delegate's launch callback instead. + let accent = try XCTUnwrap(UIColor(named: "AccentColor")) + assertSameColor(UIColor.tintColor, accent, with: light) + } + + func test_processTint_isTheAccentAsset_notSystemBlue_inDark() throws { + let accent = try XCTUnwrap(UIColor(named: "AccentColor")) + assertSameColor(UIColor.tintColor, accent, with: dark) + } + + private func assertSameColor( + _ lhs: UIColor, + _ rhs: UIColor, + with traits: UITraitCollection, + file: StaticString = #filePath, + line: UInt = #line + ) { + let left = components(of: lhs.resolvedColor(with: traits)) + let right = components(of: rhs.resolvedColor(with: traits)) + XCTAssertEqual(left.red, right.red, accuracy: 0.01, file: file, line: line) + XCTAssertEqual(left.green, right.green, accuracy: 0.01, file: file, line: line) + XCTAssertEqual(left.blue, right.blue, accuracy: 0.01, file: file, line: line) + XCTAssertEqual(left.alpha, right.alpha, accuracy: 0.01, file: file, line: line) + } + + private func components(of color: UIColor) -> (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) { + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return (red, green, blue, alpha) + } +}