listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

commit 5a3459f10ab105a5b80592d59070f76ead13e24d
parent 1c747f5cf8e2a14218deb788e9087c8267a85ac3
Author: Michael Camilleri <[email protected]>
Date:   Wed,  1 Apr 2026 23:55:42 +0900

Update AGENTS.md to account for recent changes

Diffstat:
MAGENTS.md | 18++++++++++--------
1 file changed, 10 insertions(+), 8 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -5,16 +5,16 @@ Listless is a to-do list app for Apple platforms. It is intended to run on iPhon ## Project Structure & Module Organization - `Listless.xcodeproj` coordinates three app targets: "Listless iOS" (iPhone/iPad), "Listless macOS" (native Mac), and "Listless watchOS" (Apple Watch), all sharing code from the `Listless/` directory. - `project.yml` defines the Xcode project structure for XcodeGen; run `xcodegen generate` to regenerate the project after modifying it. -- `Listless/Models` owns `ItemEntity` (NSManagedObject), `ItemStore` (plain `final class` wrapping Core Data operations), and Core Data model definitions; keep CloudKit configuration inside `Listless/Sync`. +- `Listless/Models` owns `ItemEntity` (NSManagedObject), `ItemValue` (plain struct snapshot of `ItemEntity` for safe use outside Core Data), `ItemStore` (plain `final class` wrapping Core Data operations), and Core Data model definitions; keep CloudKit configuration inside `Listless/Sync`. - `Listless/Extensions` holds extensions on shared types; `ItemListView+Logic.swift` and `ItemListView+SyncUI.swift` are extensions on `ItemListViewProtocol` (not the concrete struct) so SourceKit can resolve them unambiguously across both targets. -- `Listless/Helpers` holds shared non-view supporting code (`AccentColor`, `KeyboardNavigationModifier`, `ItemListTypes`, `ItemListViewProtocol`). `AccentColor.swift` defines `ColorTheme` (cases: `pilbara`, `collaroy`) with HSB gradient stops and a cached color interpolation function (`cachedItemColor`); both platforms read the selected theme from `@AppStorage("colorThemeRaw")`. `ItemListTypes.swift` defines the `FocusField` and `DragState` enums as top-level types (shared by both platform `ItemListView` structs) and the `FocusStateData` struct that manages selection state — including `inactiveSelections` for discontinuous selections created by Cmd+Click toggle on macOS. `ItemListViewProtocol.swift` defines the `@MainActor ItemListViewProtocol` that both structs conform to, declaring the shared property contract (`items`, `store`, `syncMonitor`, `managedObjectContext`, `focusedField`, `fState`, `dragState`, `draftPlacement`, `draftTitle`, `didStartDrag()`, `clearDraftItemUI()`). +- `Listless/Helpers` holds shared non-view supporting code (`AccentColor`, `KeyboardNavigationModifier`, `ItemListTypes`, `ItemListViewProtocol`). `AccentColor.swift` defines `ColorTheme` (cases: `pilbara`, `collaroy`) with HSB gradient stops and a cached color interpolation function (`cachedItemColor`); both platforms read the selected theme from `@AppStorage("colorThemeRaw")`. `ItemListTypes.swift` defines the `FocusField` and `DragState` enums as top-level types (shared by both platform `ItemListView` structs) and the `FocusStateData` struct that manages selection state — including `inactiveSelections` for discontinuous selections created by Cmd+Click toggle on macOS. `ItemListViewProtocol.swift` defines the `@MainActor ItemListViewProtocol` that both structs conform to, declaring the shared property contract (`items`, `store`, `syncMonitor`, `managedObjectContext`, `focusedField`, `fState`, `dragState`, `draftPlacement`, `draftTitle`, `didStartDrag()`, `revealDraftItemUI(at:animated:)`, `clearDraftItemUI(at:hasTitle:)`). - `ListlessiOS/` contains the iOS app entry point, organised into three subdirectories: - `Views/` — iOS-specific view components (`ItemListView`, `ItemRowView`, `DraftRowView`, `PullToCreate`, `PullToClear`, `UndoToast`, `SettingsView`, `SyncDiagnosticsView`, `AboutView`). - - `Helpers/` — gesture recognizers, UIKit representables, color definitions, and platform-shim view modifiers (`TappableTextField`, `ItemRowSwipeGesture`, `ItemRowDragGesture`, `AppColors`, `HoverCursorModifier`, etc.). + - `Helpers/` — gesture recognizers, UIKit representables, color definitions, platform-shim view modifiers, and tutorial seeding (`TappableTextField`, `ItemRowSwipeGesture`, `ItemRowDragGesture`, `AppColors`, `HoverCursorModifier`, `TutorialSeeder`, `ItemCardModifier`, `ItemRowMetrics`, `FPSOverlay`, etc.). - `Extensions/` — platform-specific extensions on shared types (`ItemListView+NavigationHeader`, `ItemListView+Toolbar`, `ItemListView+PullToCreate`, `ItemListView+PullToClear`, `ItemListView+PullGestures`, `ItemListView+Drag`, `ItemListView+Undo`). -- `ListlessMac/` contains the macOS app entry point with the same three-subdirectory structure as `ListlessiOS/` (`Views/` includes `ItemListView`). +- `ListlessMac/` contains the macOS app entry point with the same three-subdirectory structure as `ListlessiOS/` (`Views/` includes `ItemListView`, `ItemRowView`, `SyncDiagnosticsView`; `Helpers/` includes `AppCommands`, `AppColors`, `ClickableTextField`, `BackgroundClickMonitor`, `ItemRowDragGesture`, etc.). - `ListlessWatch/` contains the watchOS app entry point and a simplified `Views/` subdirectory (`ItemListView`, `ItemRowView`). The watchOS target selectively includes only `Listless/Models`, `Listless/Sync`, and `Listless/Helpers/AccentColor.swift` — it does not use `ItemListViewProtocol` or the shared extensions. The watch app is read-only (no creating, editing, reordering, or deleting) and supports toggling item completion only. -- `Tests/Unit` covers ordering, editing, and persistence; `Tests/Support` holds shared test helpers and fixtures. +- `Tests/Unit` covers ordering, editing, persistence, and CloudKit error classification; `Tests/UI` holds iOS and macOS UI tests; `Tests/Support` holds shared test helpers and fixtures. ## Deployment Targets - iOS 18.0, macOS 14.0, watchOS 11.0 (defined in `project.yml`). @@ -27,6 +27,7 @@ Listless is a to-do list app for Apple platforms. It is intended to run on iPhon - `xcodebuild -scheme "Listless iOS" -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build` builds the iOS app (includes embedded watchOS app). - `xcodebuild -scheme "Listless watchOS" -destination 'platform=watchOS Simulator,name=Apple Watch Series 10 (46mm),OS=11.5' build` builds the watchOS app standalone. - `xcodebuild test -scheme "Listless iOS" -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2'` runs unit + UI tests. +- `Scripts/test-ios-ui.sh` and `Scripts/test-macos-ui.sh` run platform-specific UI tests. - `swift format lint --recursive .` must be clean before opening a PR. ## App Store Connect Release @@ -58,7 +59,7 @@ Listless is a to-do list app for Apple platforms. It is intended to run on iPhon - Use SwiftUI + Observation (`@Observable`), indent four spaces, and prefer trailing commas in builders. - Models are nouns (`ItemEntity`), views end with `View`, and services end with `Service`; keep async methods verb-first (`syncItems()`). - Centralize state in `@Observable ItemStore` that wraps Core Data operations; mutations flow through intent methods like `complete(itemID:)`. -- `ItemStore.createItem(title:atBeginning:)` accepts an `atBeginning` flag (default `false`); when `true` assigns `minSortOrder - 1000` to prepend the item before all existing active items. `createItem` does **not** save — callers must call `store.save()` explicitly when they want to persist. This keeps empty placeholder items (from background tap or Return) in-memory only until the user types, avoiding iCloud sync of transient objects. +- `ItemStore.createItem(title:atBeginning:sortOrder:)` accepts an `atBeginning` flag (default `false`); when `true` assigns `minSortOrder - 1000` to prepend the item before all existing active items. An optional `sortOrder` parameter allows callers (e.g. `TutorialSeeder`) to specify an explicit sort position. `createItem` does **not** save — callers must call `store.save()` explicitly when they want to persist. This keeps empty placeholder items (from background tap or Return) in-memory only until the user types, avoiding iCloud sync of transient objects. - Completed items display below active ones; never reorder or edit them in-place. - For selection state in ForEach contexts, use computed Bool values + callbacks rather than passing @Binding to children (avoids SwiftUI update issues). - Both code and user-facing text use "item" terminology. The Core Data entity name remains `TaskItem` (via `@objc(TaskItem)`) to avoid CloudKit migration, but the Swift class is `ItemEntity`. @@ -68,7 +69,7 @@ Listless is a to-do list app for Apple platforms. It is intended to run on iPhon - `ItemEntity` is an `NSManagedObject` subclass (Core Data entity name: `TaskItem`, kept via `@objc(TaskItem)` to preserve CloudKit compatibility) with auto-updating `updatedAt` timestamp via `willSave()`. - CloudKit container identifier is `iCloud.net.inqk.listless`; entitlements are defined in `project.yml` and generated by XcodeGen. - Keep CloudKit configuration and Core Data setup inside `Listless/Sync`; exposing raw Core Data contexts elsewhere is discouraged. -- `KeyValueSyncBridge` (`Listless/Sync/KeyValueSyncBridge.swift`) bridges `@AppStorage` keys to `NSUbiquitousKeyValueStore` for iCloud sync of user preferences. Currently syncs `"headingText"` (the customisable list title). On startup it pulls cloud values into `UserDefaults`; bidirectional observation keeps the two stores in sync with an `isSyncing` flag to prevent feedback loops. To sync additional preferences, add the key to the `keys` set in `ListlessiOSApp.init()`. +- `KeyValueSyncBridge` (`Listless/Sync/KeyValueSyncBridge.swift`) bridges `@AppStorage` keys to `NSUbiquitousKeyValueStore` for iCloud sync of user preferences. Currently syncs `"listName"` (the customisable list title) and `"colorTheme"` (the selected accent colour theme). On startup it pulls cloud values into `UserDefaults`; bidirectional observation keeps the two stores in sync with an `isSyncing` flag to prevent feedback loops. To sync additional preferences, add the key to the `keys` set in `ListlessiOSApp.init()`. - When adding fields to the Core Data model, update `Listless.xcdatamodeld`, add migration mappings if needed, and document changes in `Docs/Schema.md`. ## Testing Guidelines @@ -113,6 +114,7 @@ Listless is a to-do list app for Apple platforms. It is intended to run on iPhon - **Pull-to-create/clear**: Scroll gesture handling is in `.pullGestures()` (`ItemListView+PullGestures.swift`); visual indicators are in `ItemListView+PullToCreate.swift` and `ItemListView+PullToClear.swift`. The macOS `body` omits all of these. The pull-to-create indicator and the draft prepend row are separate siblings in the VStack (not a ZStack overlay) — the indicator collapses to zero height when the draft row appears. `revealPhantomRow()` directly sets up draft placement and focus without calling `createNewItemAtTop()`. Row spacing uses explicit `.padding(.bottom, rowGap)` on each row rather than VStack `spacing` (which is set to 0). - **Haptics**: Gated behind `@AppStorage("hapticsEnabled")`. Pull-to-create/clear thresholds and drag-start use `.light` impact weight. Draft creation uses `.sensoryFeedback` on a `draftCount` trigger. - **Overflow menu**: iOS uses an overflow menu (ellipsis button) in the navigation header (`ItemListView+NavigationHeader.swift`) instead of a standalone settings button. The menu contains Rename List, Delete All, and Settings. On iOS 26+ the menu label uses `.glassEffect(.clear)`; on older versions it falls back to an `ellipsis.circle` SF Symbol with `.buttonStyle(.plain)`. Rename List presents an alert with a text field (`isShowingRenameAlert`); Delete All presents a destructive confirmation alert (`isShowingDeleteAllAlert`). +- **Tutorial**: On first launch, `TutorialSeeder` populates the list with instructional items (swipe, drag, pull gestures). Gated by `@AppStorage("didCompleteTutorial")`; Settings includes a reset option. Tutorial seeding runs in `ListlessiOSApp.init()` and is skipped during UI tests. - **Selection on iOS/iPadOS is intentionally limited**: iOS supports single-item cursor navigation via arrow keys (for marking complete/incomplete) but does not support multi-select, Select All (Cmd+A), or Cmd+Click toggling. Full selection semantics (range select, multi-select, select all) are macOS-only. Do not add selection features to the iOS target. ## SwiftUI Implementation Notes @@ -123,7 +125,7 @@ Listless is a to-do list app for Apple platforms. It is intended to run on iPhon - **Pending focus**: Set both `pendingFocus` and `focusedField` in `createNewItem()`. Do NOT resolve in `.onAppear` (race conditions). Clear `pendingFocus` in `startEditing()`. Guard `deleteIfEmpty()` against `pendingFocus` matches. - **macOS focus gating via `WindowCoordinator.allowedFocusTarget`**: When Return/Escape ends editing via `makeFirstResponder(nil)`, SwiftUI processes the `@FocusState` change asynchronously. During reconciliation it can traverse the view hierarchy and make the first `NSTextField` become first responder, overriding the `.scrollView` assignment. The per-window `allowedFocusTarget` property on `WindowCoordinator` prevents this: `doCommandBy` sets it to `.scrollView` before `makeFirstResponder(nil)`, and `ClickableNSTextField.acceptsFirstResponder` checks it — rejecting focus unless the field matches the allowed target. Each `ClickableNSTextField` carries an `itemID` for this comparison. The gate is cleared in `onChange(of: focusedFieldBinding)` once `newValue` is non-nil (reconciliation complete). When `pendingFocus` is set (e.g. revealing a draft row), the gate is updated to allow that specific target, and `ClickableNSTextField.viewDidMoveToWindow()` claims focus when the view enters the window hierarchy (handles the case where the NSView doesn't exist yet when focus is requested). - **Text editing**: iOS uses `TappableTextField` (UIViewRepresentable wrapping `UITextView`); macOS uses `ClickableTextField` (NSViewRepresentable wrapping `NSTextField`). Both use delegate/coordinator patterns to bridge to SwiftUI. Key gotcha: `onEditingChanged` callbacks from UIKit may arrive during a SwiftUI update pass — defer via `DispatchQueue.main.async`. On iOS, `TappableTextField` defers `isDragging`-driven changes to `isEditable`/`isSelectable` via the coordinator's `setDragging(_:)` method (called from `Task { @MainActor in }` in `updateUIView`) — setting these properties synchronously in `updateUIView` causes UITextView to call `invalidateIntrinsicContentSize()`, creating a layout-to-state backward edge that triggers AttributeGraph cycle warnings. -- **Drag-and-drop**: Both platforms maintain `visualOrder` during drag and commit via `store.moveItem()` on release. macOS uses `onDrag` + `dropDestination`; iOS uses `UIGestureRecognizerRepresentable` (`ItemRowDragGesture`) with a long-press recognizer. The drag gesture is gated so it won't activate on a focused (editing) row — prevents the drag from stealing touches from the text cursor/loupe. The gesture's delegate uses `shouldBeRequiredToFailBy` to make UITextView gestures wait for the drag to fail, preventing the loupe from appearing during drag-reorder. Swipe gestures (`ItemRowSwipeGesture`) are also disabled on the focused row via an `isEditing` parameter, so horizontal swiping doesn't interfere with text selection. +- **Drag-and-drop**: Both platforms maintain `visualOrder` during drag and commit via `store.moveItem()` on release. macOS uses a custom `ItemRowDragGesture` ViewModifier (`ListlessMac/Helpers/ItemRowDragGesture.swift`); iOS uses `UIGestureRecognizerRepresentable` (`ItemRowDragGesture`) with a long-press recognizer. The drag gesture is gated so it won't activate on a focused (editing) row — prevents the drag from stealing touches from the text cursor/loupe. The gesture's delegate uses `shouldBeRequiredToFailBy` to make UITextView gestures wait for the drag to fail, preventing the loupe from appearing during drag-reorder. Swipe gestures (`ItemRowSwipeGesture`) are also disabled on the focused row via an `isEditing` parameter, so horizontal swiping doesn't interfere with text selection. - **Keyboard navigation**: macOS uses dictionary-based keybindings in `KeyboardNavigationModifier.swift`. iOS uses `KeyCommandBridge` (UIViewRepresentable) because iPadOS `@FocusState` silently fails with hardware keyboards — the iOS `body` does **not** use `.focusable()`, `.focused(equals: .scrollView)`, or `.keyboardNavigation()`. - Avoid `Spacer` inside `ScrollView` (causes unwanted scrollbar). - **Escape hatches** (`GeometryReader`, `PreferenceKey`, `UIViewRepresentable`/`NSViewRepresentable`): only after exhausting SwiftUI-native alternatives (`.frame`, `.overlay`, `.background`, `alignmentGuide`).