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) + } +}