Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 4 additions & 0 deletions InterlinedList.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -404,6 +405,7 @@
A2B2C2D2E2F20042 /* AppNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotification.swift; sourceTree = "<group>"; };
BA27DCE269D834AE6F1C569D /* MuteStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MuteStoreTests.swift; sourceTree = "<group>"; };
AB80F1A1C0DE000000000004 /* ThemePreferenceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ThemePreferenceTests.swift; sourceTree = "<group>"; };
AC102ACC0000000000000002 /* AppAccentColorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppAccentColorTests.swift; sourceTree = "<group>"; };
A2B2C2D2E2F20044 /* FollowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowState.swift; sourceTree = "<group>"; };
A2B2C2D2E2F20046 /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = "<group>"; };
A2B2C2D2E2F20048 /* FollowRequestsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRequestsView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
51 changes: 25 additions & 26 deletions InterlinedList/InterlinedListApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
1 change: 0 additions & 1 deletion InterlinedList/Views/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
68 changes: 68 additions & 0 deletions InterlinedListTests/ServiceTests/AppAccentColorTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading