From c2199f036e377f97a4dce84be7dc9d0ce8811fb7 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Mon, 31 Aug 2026 10:33:51 -0700 Subject: [PATCH] docs: trim CLAUDE.md to essentials; refresh agents & skills; add worktree workflow - CLAUDE.md 139 -> 65 lines: keep the non-obvious rules (three encoders, the 401 contract), Build & Test, current gotchas, and doc pointers; drop re-derivable architecture prose and all stale references (list folders, GAP-*.md, wrong file counts). - Agents: add a pivotal "Verification is non-negotiable" section to swift-dev and verify-first framing to release-manager / qa-screenshotter; fix stale list-folder refs and the dead subscription-permissions-update.md pointer. - Skills: add a git-worktree workflow (default for feature work) to comment-and-commit and commit-and-pr; fix the non-compiling UserList(folderId:) examples in unit-test / ios-review (lists nest via parentId now). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V16mvNBWyYLncSQL8isDNF --- .claude/agents/qa-screenshotter.md | 2 + .claude/agents/release-manager.md | 4 +- .claude/agents/swift-dev.md | 17 ++- .claude/commands/comment-and-commit.md | 21 +++- .claude/commands/commit-and-pr.md | 11 ++ .claude/commands/ios-review.md | 2 +- .claude/commands/unit-test.md | 19 ++- CLAUDE.md | 168 +++++++------------------ 8 files changed, 105 insertions(+), 139 deletions(-) diff --git a/.claude/agents/qa-screenshotter.md b/.claude/agents/qa-screenshotter.md index 1bcc7f3..241312c 100644 --- a/.claude/agents/qa-screenshotter.md +++ b/.claude/agents/qa-screenshotter.md @@ -17,6 +17,8 @@ tools: Read, Bash, Write, Edit You drive **InterlinedList** in the iOS Simulator to produce release-quality screenshots and catch flow-breaking regressions before submission. You do not write app features or fix bugs — report findings precisely and hand fixes to `swift-dev`. +**Verify what you claim.** A pass you didn't actually observe, or a screenshot at the wrong dimensions, defeats the point. Only mark a smoke-test step passed after you drove it and watched the result this session, and only accept a screenshot after confirming its pixel dimensions (below). When something fails, report the exact screen/action/expected-vs-actual so `swift-dev` can reproduce it without re-walking the flow. + ## Required device targets (per `App-Store-Deployment-Checklist.md`) - **6.9"**: iPhone 16 Pro Max — screenshots must be 1320 × 2868 px. diff --git a/.claude/agents/release-manager.md b/.claude/agents/release-manager.md index 56f7e14..1cc6108 100644 --- a/.claude/agents/release-manager.md +++ b/.claude/agents/release-manager.md @@ -18,13 +18,15 @@ tools: Read, Edit, Write, Bash You own the release/submission process for **InterlinedList**, a SwiftUI iOS app heading toward its first App Store submission. You do not write Swift feature code — that's `swift-dev`'s job. Your job is tracking, verifying, and reporting release readiness so nothing slips silently. +**Verification is the whole job.** A release tracker that reports unverified status is worse than none — it manufactures false confidence. Never flip a checklist box, call a blocker cleared, or report "ready" on plausibility alone: verify it with the commands below (or an explicit user confirmation) and record how you verified it. + ## Source of truth documents Always start by reading these, in this order: 1. `App-Store-Deployment-Checklist.md` — the living pre-flight checklist (checkboxes for feature gates, credentials, Xcode project config, ASC record, assets). 2. `App-Store-Deployment.md` — fuller feature-completion status and submission narrative. 3. `the-gaps.md` — the merged iOS↔web parity/gap doc: iOS-side defects and feature gaps **and** the backend/API work (Bearer-auth fixes, moderation docs, push contract, verb mismatches) needed to unblock submission, with ready-to-paste prompts for the `interlinedlist.com` team. -4. `subscription-permissions-update.md` — any pending subscription/permissions changes. +4. `the-gaps-access.md` — access/subscription gating notes (which features are subscriber-gated, and how). Never assume a checklist item is done because it looks plausible — verify it (see below) before checking a box. diff --git a/.claude/agents/swift-dev.md b/.claude/agents/swift-dev.md index a4400d8..56310b1 100644 --- a/.claude/agents/swift-dev.md +++ b/.claude/agents/swift-dev.md @@ -15,6 +15,17 @@ tools: Read, Edit, Write, Bash, Skill You are an expert iOS/Swift engineer working on **InterlinedList**, a SwiftUI app that connects to the `interlinedlist.com` API. +## Verification is non-negotiable + +Nothing is "done" until it is verified. Every change must clear this bar before you report back: + +1. **Build succeeds** — see *Build verification* below. Never leave compile errors for the user. +2. **Tests written and green** — new `APIClient` methods, model decoding, and branching logic get unit tests (see *Unit tests*); run the suite and fix every failure. +3. **Review pass** — invoke `/ios-review` over the changed files before calling a feature complete; `/solid-check` if you changed structure across files. +4. **Report honestly** — state exactly what you built **and ran**, with the result. If a step was skipped or a test still fails, say so plainly; never imply verification you didn't perform. + +If you genuinely cannot verify something (no simulator, a flow needing live GitHub/OAuth), say precisely what remains unverified and how the user can confirm it — do not report unverifiable work as complete. + ## Mandatory principles ### SOLID @@ -34,7 +45,7 @@ You are an expert iOS/Swift engineer working on **InterlinedList**, a SwiftUI ap - **No `DispatchQueue.main.async`** — use `@MainActor` annotations or `MainActor.run {}`. - **No comments** unless the reason is non-obvious (API quirk, hidden constraint, workaround). Do not describe what the code does; well-named identifiers already do that. - **camelCase vs snake_case bodies** — `APIClient` has two encoder families and choosing wrong fails **silently** server-side. Use `postCamel`/`putCamel`/`patchCamel` (plain `camelCaseEncoder`) for the **many** camelCase endpoints (messages, lists, documents, organizations, watchers, identities, change-email, notification-preferences, message metadata, …); use `post`/`put`/`patch` (snake_case `encoder`) for the rest. **Check the existing method for that endpoint before adding a new one** — don't assume `/api/messages` is the only camelCase route. -- **Empty-string == nil** — **both** `ListFolder.parentId` **and** `UserList.folderId` may arrive as `""` instead of `null`. Treat empty-string the same as absent (this is what `ListTreeNode.buildTree` does). +- **Empty-string == nil** — lists nest via `UserList.parentId`, which may arrive as `""` instead of `null`. Treat empty-string the same as absent (this is what `ListTreeNode.buildTree` does). List folders no longer exist; only documents have folders (see below). - **Token in Keychain only** — never `UserDefaults` or in-memory across app restarts without Keychain backing. - Every new `View` file needs a `#Preview` macro block. - Every interactive element without an obvious label needs `.accessibilityLabel`. @@ -42,7 +53,7 @@ You are an expert iOS/Swift engineer working on **InterlinedList**, a SwiftUI ap ## File layout ``` InterlinedList/Models/ Codable structs, lightweight computed properties only -InterlinedList/Views/ SwiftUI views — one public struct per file (~41 files) +InterlinedList/Views/ SwiftUI views — one public struct per file (~52 files) InterlinedList/Services/ APIClient, AuthState, AppDataStore, DataCache, KeychainService, OAuthCoordinator, URLSessionProtocol, PushService, ComposeImageUploader / ImageUploadProcessor @@ -76,7 +87,7 @@ Services throw `APIError`. Views catch it and set a `String?` error state for di Every non-trivial feature implementation must be accompanied by unit tests. Tests live in `InterlinedListTests/` (create the target if it does not exist). Follow these rules: ### What to test -- **Models:** `Codable` round-trips. Encode a struct to JSON and decode it back; assert all fields survive. Test edge cases: `null` vs empty-string for optional fields like `folderId`/`parentId`, unknown enum cases, missing keys that should produce `nil` not a crash. +- **Models:** `Codable` round-trips. Encode a struct to JSON and decode it back; assert all fields survive. Test edge cases: `null` vs empty-string for optional fields like `UserList.parentId`, unknown enum cases, missing keys that should produce `nil` not a crash. - **APIClient methods:** Use a mock `URLSession` (inject via `APIClient(session:)`) that returns canned `Data` + `HTTPURLResponse`. Assert the correct URL path, HTTP method, and `Authorization` header are sent. Assert the decoded return value matches the canned fixture. Test 401, 403, and 5xx paths throw the expected `APIError` case. - **Pure logic / computed properties:** `ListTreeNode.buildTree`, `JSONValue.displayString`, `User.displayNameOrUsername`, date-formatting helpers — test the logic in isolation, no network needed. diff --git a/.claude/commands/comment-and-commit.md b/.claude/commands/comment-and-commit.md index 65df7b0..00b3e18 100644 --- a/.claude/commands/comment-and-commit.md +++ b/.claude/commands/comment-and-commit.md @@ -13,7 +13,21 @@ Optional argument (`$ARGUMENTS`): extra context to fold into the message — a t Co-Authored-By: Claude Opus 4.8 (1M context) ``` - **Never push** from this command. -- **Never commit on the default branch** (`main`): if HEAD is `main`, create and switch to a descriptive feature branch first. +- **Never commit on the default branch** (`main`): if HEAD is `main`, isolate the work first (see Worktrees). +- Every commit also ends with any session trailer the harness specifies (e.g. `Claude-Session: `) in addition to the `Co-Authored-By` line above. + +## Worktrees (default for feature work) + +Isolated/feature work happens in a dedicated **git worktree**, not by switching branches in the primary checkout — this keeps the main checkout clean and lets parallel efforts coexist. Agent worktrees live under `.claude/worktrees/`. + +- **Know where you are:** `git worktree list` (all trees + their branches) and `git rev-parse --show-toplevel` (current root). The first `worktree list` entry is the primary checkout. +- **Already in a worktree** (a `.claude/worktrees/…` or `../interlinedlist-ios-` dir): just commit here — the steps below are identical. Still never commit on the base branch. +- **Starting fresh isolated work** from the primary checkout: create a worktree with its own branch instead of editing in place, then work and commit inside it: + ```bash + git worktree add ../interlinedlist-ios- -b / + ``` + (If the harness exposes an `EnterWorktree` tool, prefer it — it does the same thing.) +- **Changes already dirty in the primary checkout** (you edited in place before branching): don't fight it — branch in place (`git switch -c /`) and commit; use a worktree from the outset next time. `git worktree add` creates a *clean* tree and will not carry your uncommitted edits. ## Steps @@ -24,11 +38,12 @@ Optional argument (`$ARGUMENTS`): extra context to fold into the message — a t git diff HEAD # full diff: staged + unstaged ``` -2. **Safety-check the branch**: +2. **Safety-check the branch / worktree** (see Worktrees above): ```bash git rev-parse --abbrev-ref HEAD + git worktree list # confirm whether you're in the primary checkout or a worktree ``` - If it prints `main`, branch first: `git switch -c /`. + If HEAD is `main`: create a worktree for the work (`git worktree add ../interlinedlist-ios- -b /`) and continue there, or — if changes are already dirty here — `git switch -c /` in place. 3. **Review, then stage.** Read the diff and decide what belongs in this commit. By default stage everything that's part of the work: ```bash diff --git a/.claude/commands/commit-and-pr.md b/.claude/commands/commit-and-pr.md index 5a52604..b4100b7 100644 --- a/.claude/commands/commit-and-pr.md +++ b/.claude/commands/commit-and-pr.md @@ -14,6 +14,10 @@ Optional argument (`$ARGUMENTS`): a PR title hint, a base-branch override, or ex ``` - Opening a PR is outward-facing: verify the branch, base, and commit list look right before creating it. +## Worktrees + +`/comment-and-commit` (step 1) creates or lands the commit in a dedicated git **worktree** by default (see its *Worktrees* section) so feature work stays isolated from the primary checkout. Everything here runs from **that** worktree — `git push -u origin HEAD` and `gh pr create` act on the current worktree's branch, so no special handling is needed; just stay in it. Confirm with `git worktree list`. After the PR merges, clean the worktree up (step 7). Never remove a worktree that has uncommitted changes or an un-pushed branch. + ## Steps 1. **Commit waiting work.** Perform every step of `/comment-and-commit` so the tree is clean and all work is committed. If there's nothing to commit but the branch is already ahead of the base, continue. @@ -55,3 +59,10 @@ Optional argument (`$ARGUMENTS`): a PR title hint, a base-branch override, or ex ``` 6. **Report the PR URL** that `gh` prints. If `gh` isn't authenticated, surface the error and tell the user to run `! gh auth login` in the prompt (so the interactive login lands in this session), then re-run. + +7. **Clean up the worktree — only after the PR is merged.** If the commit landed in a dedicated worktree, remove it once merged so it doesn't linger: + ```bash + git worktree remove ../interlinedlist-ios- # run from the primary checkout, not inside the worktree + git worktree prune + ``` + Skip if the work was committed in place (no dedicated worktree) or the PR isn't merged yet — in that case leave the worktree and say so, so the user can remove it after merge. diff --git a/.claude/commands/ios-review.md b/.claude/commands/ios-review.md index 68558b5..250c35d 100644 --- a/.claude/commands/ios-review.md +++ b/.claude/commands/ios-review.md @@ -28,7 +28,7 @@ Perform a focused code review of the Swift/SwiftUI changes on the current branch ### Project-specific - [ ] Correct encoder per endpoint: camelCase bodies use the `postCamel`/`putCamel`/`patchCamel` helpers, snake_case bodies use `post`/`put`/`patch` — mismatches fail silently server-side (check the existing method, don't assume) - - [ ] Empty-string `folderId` / `parentId` treated same as `nil` (both `UserList.folderId` and `ListFolder.parentId`) + - [ ] Empty-string `UserList.parentId` treated same as `nil` (list nesting via `parentId`; list folders no longer exist) - [ ] Document-folder path-scoping honored: folder reads/creates use `/api/documents/folders/{id}/documents`; root routes are root-only - [ ] Tokens stored in Keychain only — no `UserDefaults` - [ ] New `.swift` files registered in `project.pbxproj` (no synced groups) diff --git a/.claude/commands/unit-test.md b/.claude/commands/unit-test.md index 8ab7794..4df8a5c 100644 --- a/.claude/commands/unit-test.md +++ b/.claude/commands/unit-test.md @@ -123,10 +123,9 @@ final class UserListCodableTests: XCTestCase { func test_decode_emptyParentIdTreatedAsNil() throws { let json = #"{"id":"1","title":"L","parentId":"","createdAt":"2024-01-01T00:00:00Z"}"# let list = try JSONDecoder().decode(UserList.self, from: Data(json.utf8)) - // folderId maps parentId; empty string is NOT nil at decode time — test the usage guard - XCTAssertEqual(list.folderId, "") - // The tree-builder treats "" as absent — verify that invariant here - XCTAssertTrue((list.folderId ?? "").isEmpty) + // "" is NOT nil at decode time; the tree-builder is what treats it as absent + XCTAssertEqual(list.parentId, "") + XCTAssertTrue((list.parentId ?? "").isEmpty) } } ``` @@ -135,20 +134,20 @@ final class UserListCodableTests: XCTestCase { ```swift final class ListTreeNodeTests: XCTestCase { - func test_buildTree_rootListWithNoFolder_appearsAtRoot() { - let list = UserList(id: "1", name: "Root", description: nil, folderId: nil, + func test_buildTree_rootListWithNoParent_appearsAtRoot() { + let list = UserList(id: "1", name: "Root", description: nil, parentId: nil, isPublic: nil, createdAt: "2024-01-01T00:00:00Z", updatedAt: nil, itemCount: nil) - let nodes = ListTreeNode.buildTree(folders: [], lists: [list]) + let nodes = ListTreeNode.buildTree(lists: [list]) XCTAssertEqual(nodes.count, 1) XCTAssertEqual(nodes.first?.name, "Root") } - func test_buildTree_listWithEmptyFolderIdTreatedAsRoot() { - let list = UserList(id: "1", name: "L", description: nil, folderId: "", + func test_buildTree_listWithEmptyParentIdTreatedAsRoot() { + let list = UserList(id: "1", name: "L", description: nil, parentId: "", isPublic: nil, createdAt: "2024-01-01T00:00:00Z", updatedAt: nil, itemCount: nil) - let nodes = ListTreeNode.buildTree(folders: [], lists: [list]) + let nodes = ListTreeNode.buildTree(lists: [list]) XCTAssertEqual(nodes.count, 1) } } diff --git a/CLAUDE.md b/CLAUDE.md index 8c9c85d..60bf51d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,139 +1,65 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for Claude Code in this repo. Follow it exactly — it overrides default behavior. -## Project Overview +## Project -**InterlinedList** is a native iOS/SwiftUI social list-sharing app that connects to the `interlinedlist.com` backend API. Users authenticate (email/password or OAuth), compose messages, browse a feed, manage nested lists/folders and documents, follow people, and join organizations. +**InterlinedList** — native iOS/SwiftUI social list-sharing app talking to the `interlinedlist.com` API (auth, feed/compose, nested lists, documents, follows, organizations). -- **Language:** Swift 5.9+ -- **UI framework:** SwiftUI (UIKit only via `UIResponder` for keyboard dismissal and `ASWebAuthenticationSession`/`UIApplication` for OAuth presentation) -- **Minimum target:** iOS 17 (uses `ContentUnavailableView`, `NavigationStack`, `onChange(of:_:)` two-param form) -- **No third-party dependencies** — pure Apple frameworks only -- **API base:** `https://interlinedlist.com` (overridable via `ILAPIBaseURL` in `Info.plist`; empty string = production) +- Swift 5.9+, SwiftUI (UIKit only for keyboard dismissal + `ASWebAuthenticationSession`/`UIApplication` OAuth). Minimum target **iOS 17**. +- **No third-party dependencies** — Apple frameworks only. +- API base `https://interlinedlist.com`, overridable via `ILAPIBaseURL` in `Info.plist` (empty = production). +- Layout: `InterlinedList/{Models,Views,Services}` + `InterlinedListApp.swift`; tests in `InterlinedListTests/{APIClientTests,ModelTests,ServiceTests,E2E}`. -## Directory Layout +## The non-obvious rules (getting these wrong fails silently) -``` -InterlinedList/ - Models/ # Codable value types — no logic beyond computed properties - Views/ # SwiftUI views and subviews — one public struct per file (~34 files) - Services/ # APIClient, AuthState, AppDataStore, DataCache, KeychainService, - # OAuthCoordinator, URLSessionProtocol - InterlinedListApp.swift # @main entry; wires env objects + deep-link handling -InterlinedListTests/ - APIClientTests/ # Per-domain HTTP tests using MockURLSession - ModelTests/ # Codable round-trip + decoding-quirk tests - ServiceTests/ # KeychainService etc. - E2E/ # Read-only live-API smoke tests, gated on credentials -InterlinedList.xcodeproj/ -InterlinedList.xctestplan # Single test target; parallelization disabled (see Build & Test) -Resources/ # Logo assets, SVGs -.claude/ # agents/ (subagents), commands/ (slash-command skills) -GAP-*.md # Living design/roadmap docs (see "Reference docs") -``` - -## Architecture - -### Dependency injection & app composition -`InterlinedListApp` owns three `@StateObject`s injected at the root and consumed via `@EnvironmentObject` throughout the view tree: -- **`AuthState`** — session/user lifecycle (`@MainActor ObservableObject`). -- **`AppDataStore`** — central cached data layer for feed, lists, documents, and badge counts. -- **`AppRouter`** — holds `pendingDeepLink`; drives sheet presentation for custom-scheme URLs. - -When `authState.hasToken` flips to `false`, the app calls `store.reset()` to clear cached data. - -### `APIClient` — HTTP only -- `final class` singleton (`shared`) with an **injectable `URLSessionProtocol`** so tests swap in `MockURLSession` without subclassing `URLSession` (its async `data(for:)` lives in an extension and can't be overridden). -- Holds the Bearer token in memory (`setBearerToken`); does **not** own auth state or touch the UI. -- **Three coders, chosen per endpoint** — getting this wrong is the most common bug: - - `decoder` — `convertFromSnakeCase` for all responses. - - `encoder` (`convertToSnakeCase`) via `post`/`put`/`patch` — for snake_case request bodies. - - `camelCaseEncoder` (plain) via `postCamel`/`putCamel`/`patchCamel` — for endpoints that expect **camelCase** bodies. **Many** endpoints use the camel variants (messages, lists, organizations, watchers, identities, change-email, …), not just `/api/messages`. Check the existing method before adding a new one. -- All requests funnel through `perform(_:)` → `checkResponse(_:_:)`, which throws typed `APIError` (`.status(401)`, `.server(msg)` from `{"error": ...}` bodies, `.conflict`, `.decoding`, `.network`). `os.Logger` logs method/path/status (never tokens). - -### Auth & the 401 contract (important) -A `401` does **not** automatically mean the session is dead. Some backend endpoints only accept session-cookie auth and reject a valid Bearer token. So: -- `APIClient` simply throws `APIError.status(401)`. -- Views catch it and call **`authState.handleUnauthorized()`**, which re-validates against `GET /api/user`. Only if `/api/user` *itself* returns 401 does it `logout()`. Network errors keep the user logged in. -- Follow this pattern for any new authenticated call — do not log the user out directly on a 401 from a feature endpoint. - -### `AppDataStore` — prefetch + offline cache + optimistic updates -- `prefetchAll(userId:)` fans out feed/lists/documents/counts concurrently with a `TaskGroup`. -- Reads/writes a per-user on-disk cache via **`DataCache`** (JSON files under `Caches/ILDataCache/`, keyed `"_feed"` etc.) so screens render instantly from cache, then refresh. -- Mutations are **optimistic** (`removeList`, `insertDocument`, …) and immediately re-persist to cache. -- Swallows `APIError.status(401)` during background refresh (auth is handled elsewhere); only surfaces an error string when there's no cached data to show. - -### OAuth & deep links -- **`OAuthCoordinator`** wraps `ASWebAuthenticationSession`, opening `/api/auth//authorize?redirect_uri=interlinedlist://oauth/callback` and parsing the returned `?token=...`. -- Custom URL scheme is **`interlinedlist://`**. `InterlinedListApp.handleDeepLink` routes `reset-password`, `verify-email`, `verify-email-change` (oauth callbacks are captured by the session itself). -- `OAuthProvider.supportsNativeAuth` is now `true` for **all** providers, including **GitHub**. The backend mobile branch has shipped (backend `b6a68ce`, "A1"): `/api/auth/github/authorize` persists the custom-scheme `redirect_uri` in its `oauth_state` cookie, and the callback detects a mobile redirect (`isMobileRedirectUri`) and hands back `interlinedlist://oauth/callback?token=…` instead of setting a web cookie and redirecting to `/dashboard`. So GitHub sign-in completes inside `ASWebAuthenticationSession` like every other provider. LinkedIn/Twitter remain additionally gated on a live `/status` `configured` check (`LoginView`/`RegisterView`). -- **Universal Links / Associated Domains (A2)** are in place: the `applinks:interlinedlist.com` entitlement is set and the backend publishes `apple-app-site-association` (`appID BJA9558E4B.com.interlinedlist.app`; paths `/user/*`, `/message/*`, `/lists/*`, `/documents/*`, excluding `/api/*`, `/dashboard*`, `/login*`, `/help/*`). `AppDeepLink.parse` already accepts https permalinks, so a tapped web link opens the app. Note this needs the Associated Domains capability enabled on the App ID / provisioning profile to work on device. - -### Models & dates -- `Codable` structs, logic limited to computed properties. Decode defensively — see Gotchas. -- Dates stored as `String` (ISO 8601); formatted in the view layer (`ISO8601DateFormatter` → `RelativeDateTimeFormatter`). - -## Design principles (SOLID + KISS) -- **SRP:** one `View` renders one thing; one `Service` owns one domain; `APIClient` is HTTP-only. -- **DIP:** services/view-models depend on protocols (`URLSessionProtocol`), so they're testable without a live network. -- **ISP:** keep protocols narrow; pass a closure or thin wrapper rather than a whole service into a view. -- **KISS:** flat `@State` over view models for simple screens; prefer `task {}`, `.refreshable {}`, `@EnvironmentObject` over custom schedulers; `async/await` over Combine unless Apple's built-ins fall short. +- **Three coders in `APIClient`, chosen per endpoint** — the most common bug: + - `decoder` — `convertFromSnakeCase`, all responses. + - `encoder` (`convertToSnakeCase`) via `post`/`put`/`patch` — snake_case bodies. + - `camelCaseEncoder` (plain) via `postCamel`/`putCamel`/`patchCamel` — the **many** camelCase endpoints (messages, lists, orgs, watchers, identities, change-email, …). Check the existing method before adding one. +- **401 ≠ logged out.** Some endpoints only accept session cookies and reject a valid Bearer. `APIClient` throws `APIError.status(401)`; views call `authState.handleUnauthorized()`, which re-validates `GET /api/user` and only logs out if *that* 401s. Never `logout()` on a feature-endpoint 401. +- **Token storage: Keychain only** (`KeychainService`), never `UserDefaults`. Deep-link token query items are secrets — never log them. +- **Adding a `.swift` file:** no synced groups — register it in `project.pbxproj` (the `xcodeproj` Ruby gem) or it won't compile into the target. ## Build & Test -Notes: -- `name=iPhone 16` alone is **ambiguous** across installed runtimes and can fail to resolve. Pin a concrete simulator UDID (`xcrun simctl list devices`) for reliable runs. -- **Parallelization is disabled in `InterlinedList.xctestplan`** (`parallelizable: false`). The E2E suite shares a `static` login token across tests to avoid re-hitting the rate-limited login endpoint; parallel runs use cloned simulators that don't share that state. The `-parallel-testing-enabled NO` flag below is therefore redundant reinforcement, not the source of truth — keep the plan setting in sync if you change this. +- Pin a concrete simulator **UDID** — `name=iPhone 16` alone is ambiguous across runtimes (`xcrun simctl list devices`). +- **Parallelization is disabled** (`InterlinedList.xctestplan`, `parallelizable:false`): the E2E suite shares a static login token that parallel cloned sims break. `-parallel-testing-enabled NO` is reinforcement; keep the plan setting in sync. +- Unit tests stub HTTP via `MockURLSession` (`stub`/`enqueue`) — no network. +- E2E tests (`InterlinedListTests/E2E`) hit the **live** API, **read-only**; auto-`XCTSkip` unless `INTERLINEDLIST_EMAIL`/`INTERLINEDLIST_PASSWORD` are set (process env or a gitignored `.env`). Network-flaky — for a deterministic run add `-skip-testing:InterlinedListTests/E2EReadOnlyTests`. +- CI (`.github/workflows/ios.yml`) **builds only** (no tests) on push/PR to `main`, signing disabled. ```bash -# Build for simulator -xcodebuild -scheme InterlinedList \ - -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' build - -# Run the full test suite (pin a UDID; serialize for stability) -xcodebuild -scheme InterlinedList \ - -destination 'platform=iOS Simulator,id=' \ - -parallel-testing-enabled NO test - -# Run a single test class or method -xcodebuild test -scheme InterlinedList \ - -destination 'platform=iOS Simulator,id=' \ +# Build (pin a UDID) +xcodebuild -scheme InterlinedList -destination 'platform=iOS Simulator,id=' build +# Full suite, serialized +xcodebuild -scheme InterlinedList -destination 'platform=iOS Simulator,id=' -parallel-testing-enabled NO test +# One class/method +xcodebuild test -scheme InterlinedList -destination 'platform=iOS Simulator,id=' \ -only-testing:InterlinedListTests/APIClientMessagesTests -# …/APIClientMessagesTests/testPostMessageUsesCamelCase (single method) - -# List simulators -xcrun simctl list devices --json | jq '.devices | to_entries[] | select(.value | length > 0)' ``` -- **Unit tests** stub HTTP through `MockURLSession` (`stub`/`enqueue` for sequenced responses) — no network needed. -- **E2E tests** (`InterlinedListTests/E2E`) hit the **live** API but are strictly **read-only**. They auto-`XCTSkip` unless `INTERLINEDLIST_EMAIL` / `INTERLINEDLIST_PASSWORD` are present, read from process env (Xcode scheme Test action or CI) or a gitignored `.env` at repo root (`EnvLoader`). -- **CI** (`.github/workflows/ios.yml`) **builds only** (no tests) on push/PR to `main`, with code signing disabled. - -## Coding Standards - -- **No comments** unless the "why" is non-obvious (hidden constraint, workaround, API quirk). -- **No force-unwrap** (`!`) in production paths — use `guard`, `if let`, or `try?` with a meaningful fallback. -- **No `DispatchQueue.main.async`** — use `@MainActor` or `.receive(on: RunLoop.main)`. -- **Accessibility:** every interactive element needs `.accessibilityLabel` if the label isn't obvious from context. -- **Preview:** every `View` file should have a `#Preview` block. -- Mark view-internal helpers `private`; mark service internals `private`/`fileprivate`. - -## Common Gotchas - -- **camelCase vs snake_case bodies:** use the right encoder/helper (`postCamel` family for camelCase endpoints). Mismatches fail silently server-side. See APIClient architecture above. -- **Don't log out on a feature-endpoint 401** — route through `authState.handleUnauthorized()` (re-validates against `/api/user`). -- **Empty-string parents:** `ListFolder.parentId` and `UserList.folderId` may arrive as `""` instead of `null` — treat both as "no parent." -- **`listsAndFolders()`** issues `GET /api/folders` then `GET /api/lists` in sequence; errors from either propagate (the UI sees a real error, not an empty list). -- **Document folders are path-scoped, not query/body-scoped.** `GET /api/documents` and `POST /api/documents` are **root-only** (the GET ignores `?folderId=`; the POST has no `folderId` field). A folder's contents come from `GET /api/documents/folders/{id}/documents`, and creating in a folder is `POST /api/documents/folders/{id}/documents`. Only `PATCH /api/documents/{id}` takes `folderId` (camelCase) to move a doc. Using the wrong route silently drops the folder and the doc lands at root. -- **GitHub-backed lists are editable, but updates must send the FULL row.** The standard `/api/lists/:id/data` routes proxy to GitHub Issues (POST→create, PUT→patch, DELETE→close); a row's `id` **is the issue number**. The backend rebuilds the issue payload from the request alone and defaults a missing `title` to `"Untitled"`, and `title` is required — so a partial `PUT` (e.g. just `{state:"closed"}`) is rejected or silently renames the issue. Always send the whole row on GitHub updates (see `ListDetailView.setGitHubState`); use `updateItem` (full row), not the single-field `updateRow`. The GitHub schema (`listSchema`) marks `number`/`url`/timestamps `isReadOnly` and types `state` as a `select` (`open`/`closed`) — the add/edit form hides read-only fields and renders `select` as a Picker. -- **Row-mutation responses return the saved row under `data`, not `row`.** `addListItem`/`updateItem`/`updateRow` decode `{ data: ListItem }` (a `row` key silently yields `noData` and the optimistic UI update is dropped). -- **GitHub-list schema comes from the server, with a client fallback.** GitHub lists have no stored `listProperty` rows (their columns are synthetic issue fields), so `GET /api/lists/:id` historically returned empty `properties` — which disabled the add button (`.disabled(schema.isEmpty)`), blanked the add/edit form, and left rows with no columns. The backend now returns the synthetic schema in `properties` (each shaped like a DB row: `id: "gh_"`, `isReadOnly`, `validationRules.options`). `ListDetailView.loadData` keeps `ListPropertyDef.gitHubIssueSchema()` as a **fallback** only when the fetched schema is empty (older/undeployed backend). Keep the client copy in sync if the backend issue schema changes. -- **Row headline isn't the first column.** `DynamicItemRow` uses `ListPropertyDef.primaryDisplayField(from:)` to pick a title/name-like field (else first editable text), and the row is tap-to-edit; don't assume `schema.first` drives the display. -- **Token storage:** Keychain only (`KeychainService`); never `UserDefaults`. Token query items from deep links are sensitive — never log them. -- **Adding a file to the project:** there are no synced/file-system groups — a new `.swift` file must be manually registered in `project.pbxproj` (use the `xcodeproj` Ruby gem) or it won't compile into the target. +## Coding standards (SOLID + KISS) + +- One `View` renders one thing; one `Service` owns one domain; `APIClient` is HTTP-only. Depend on protocols (`URLSessionProtocol`) so it's testable without a network. +- **No force-unwrap** (`!`) in production paths. **No `DispatchQueue.main.async`** — use `@MainActor`. +- **No comments** unless the "why" is non-obvious. Mark view/service internals `private`. +- Every `View` file has a `#Preview`; every non-obvious interactive element has `.accessibilityLabel`. +- Lists nest via **`UserList.parentId`** — `""` and `nil` both mean "no parent" (see `ListTreeNode.buildTree`). List folders no longer exist; documents still have folders (below). + +## Gotchas (current) + +- **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`. ## Reference docs -- `App-Store-Deployment.md` — feature completion status, required credentials/assets/costs, and the full App Store submission checklist. -- `the-gaps.md` — the merged iOS↔web parity/gap doc: iOS-side defects (D1–D5) and feature gaps **and** the backend/API work needed to unblock submission (Bearer-auth fixes, moderation endpoints, push contract confirmation, deferred API gaps), with ready-to-paste prompts for the API team. +- `App-Store-Deployment.md` / `App-Store-Deployment-Checklist.md` — submission status, credentials/assets, pre-flight checklist. +- `the-gaps.md` — merged iOS↔web parity/gap doc (iOS defects + backend asks, paste-ready prompts). `the-gaps-access.md` — access/subscription gating notes. + +## Subagents & skills (`.claude/`) + +- Agents: `swift-dev` (feature/bugfix Swift), `qa-screenshotter` (sim screenshots + smoke tests), `release-manager` (submission readiness). +- Skills: `/comment-and-commit`, `/commit-and-pr`, `/unit-test`, `/e2e-test`, `/ios-review`, `/solid-check`. +- **Verification is mandatory:** build + relevant tests must pass before any work is reported done. Do isolated feature work in a git worktree (see `/comment-and-commit`).