listless

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

commit 04a23f1982f8771de5dde84ceb8cd2cbe402f175
parent 33e450184c4460d41fab4926c06e4b01ac76c18e
Author: Michael Camilleri <[email protected]>
Date:   Thu,  5 Mar 2026 06:21:05 +0900

Simplify wording of AGENTS.md

Diffstat:
MAGENTS.md | 83++++++++++++++++++-------------------------------------------------------------
1 file changed, 19 insertions(+), 64 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -3,15 +3,15 @@ ## Project Structure & Module Organization - `Listless.xcodeproj` coordinates two app targets: "Listless iOS" (iPhone/iPad) and "Listless macOS" (native Mac), both 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 `TaskItem` (NSManagedObject), `TaskStore` (@Observable wrapper), and Core Data model definitions; keep CloudKit configuration inside `Listless/Sync`. +- `Listless/Models` owns `TaskItem` (NSManagedObject), `TaskStore` (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; `TaskListView+Logic.swift` and `TaskListView+SyncUI.swift` are extensions on `TaskListViewProtocol` (not the concrete struct) so SourceKit can resolve them unambiguously across both targets. - `Listless/Helpers` holds shared non-view supporting code (`AccentColor`, `KeyboardNavigationModifier`, `TaskListTypes`, `TaskListViewProtocol`). `TaskListTypes.swift` defines the `FocusField` and `DragState` enums as top-level types (shared by both platform `TaskListView` structs). `TaskListViewProtocol.swift` defines the `@MainActor TaskListViewProtocol` that both structs conform to, declaring the shared property contract (`tasks`, `store`, `syncMonitor`, `managedObjectContext`, `focusedField`, `selectedTaskID`, `pendingFocus`, `dragState`, `didStartDrag()`). - `ListlessiOS/` contains the iOS app entry point, organised into three subdirectories: - - `Views/` — iOS-specific view components (`TaskListView`, `TaskRowView`, `PullToCreate`, `SettingsView`, `SyncDiagnosticsView`, `AboutView`). + - `Views/` — iOS-specific view components (`TaskListView`, `TaskRowView`, `PullToCreate`, `PullToClear`, `UndoToast`, `SettingsView`, `SyncDiagnosticsView`, `AboutView`). - `Helpers/` — gesture recognizers, UIKit representables, color definitions, and platform-shim view modifiers (`TappableTextField`, `TaskRowSwipeGesture`, `TaskRowDragGesture`, `AppColors`, `HoverCursorModifier`, etc.). - - `Extensions/` — platform-specific extensions on shared types (`TaskListView+NavigationHeader`, `TaskListView+Toolbar`, `TaskListView+PullToCreate`, `TaskListView+Drag`). + - `Extensions/` — platform-specific extensions on shared types (`TaskListView+NavigationHeader`, `TaskListView+Toolbar`, `TaskListView+PullToCreate`, `TaskListView+PullToClear`, `TaskListView+PullGestures`, `TaskListView+Drag`, `TaskListView+Undo`). - `ListlessMac/` contains the macOS app entry point with the same three-subdirectory structure as `ListlessiOS/` (`Views/` includes `TaskListView`); an excluded `AppKit/` subdirectory holds the reverted AppKit implementation. -- `Tests/Unit` covers ordering, editing, and persistence, while `Tests/UI` handles simulator automation using launch arguments defined in `Tests/Support` fixtures. +- `Tests/Unit` covers ordering, editing, and persistence; `Tests/Support` holds shared test helpers and fixtures. ## Build, Test, and Development Commands - `xed .` launches the project in Xcode. @@ -98,68 +98,23 @@ - **Plain (unmodified) key commands** (Up, Down, Space, Return, Delete) are still handled by `KeyCommandBridge`'s `keyCommands` property with `wantsPriorityOverSystemBehavior = true`. Command-modified shortcuts go through `buildMenu` → responder chain → `KeyCaptureView` action methods. ## iOS Implementation Notes -- **Platform-specific inits**: `ListlessiOS/Views/TaskRowView.swift` and `ListlessMac/Views/TaskRowView.swift` have diverged — iOS takes `isDragging: Binding<Bool>` while macOS does not. This is fine because each platform has its own `TaskListView.swift` in its platform-specific `Views/` directory, so identical call-site signatures are not required. When adding new parameters, update both `TaskRowView` inits and both `TaskListView` bodies. -- **Swipe gesture**: Pure SwiftUI — `DragGesture(minimumDistance: 10, coordinateSpace: .local)` applied via `.simultaneousGesture()` for scroll coexistence. Direction discrimination happens in `handleDragChanged`: `abs(horizontalTranslation) > verticalTranslation + 10` must hold before any swipe offset is applied, so vertical scrolls pass through without activating the swipe. Haptic feedback uses `@State var hapticTrigger` toggled in `triggerAction` with a `.sensoryFeedback(.impact(weight: .medium), trigger: hapticTrigger)` modifier on the ZStack. - - `isDragging` is passed as `@Binding var isDragging: Bool` all the way from `TaskListView` (`@State var isDragging: Bool`, iOS-only, declared in `ListlessiOS/Views/TaskListView.swift`) through `TaskRowView` to `TaskRowSwipeGesture`. The `@Binding` drives both the `including: isDragging ? .none : .all` gesture mask (disables recognition entirely during drag reordering) and the `guard !isDragging` check inside `onChanged`. - - `isActive` and `isEditing` were removed from the gesture interface — `isActive` was always `true` at the call site (the gesture is only applied to active task rows in the ForEach), and `isEditing` can never be true during an active swipe (you can't initiate a horizontal pan while the keyboard is up). -- **Incremental build**: iOS views are built in stages. The stub accepts the full interface; behaviour is filled in progressively. Keep the init signature in sync with macOS when adding parameters. -- **Title and navigation**: `ListlessiOSApp.swift` uses a plain `WindowGroup` with no `NavigationStack`. The navigation header title is customisable via `@AppStorage("headingText")` (default `"Items"`, synced to iCloud via `KeyValueSyncBridge`). It is rendered as a SwiftUI `Text` inside the `ScrollView` via the `navigationHeader` computed property defined in `ListlessiOS/Extensions/TaskListView+NavigationHeader.swift`; the macOS `body` in `ListlessMac/Views/TaskListView.swift` simply omits the call, so no macOS stub is needed. A `.overlay(alignment: .top)` of `Color.outerBackground.opacity(0.9).ignoresSafeArea(edges: .top).frame(height: 0)` covers the status bar region so scroll content doesn't show under the clock and system icons. -- **Settings screen**: `SettingsView` (`ListlessiOS/Views/SettingsView.swift`) is presented as a sheet from the gear icon in the navigation header. Contains four sections: list title (`TextField` bound to `@AppStorage("headingText")`), appearance picker (segmented: System/Light/Dark, bound to `@AppStorage("appearanceMode")`), `NavigationLink` to `SyncDiagnosticsView`, and `NavigationLink` to `AboutView`. The `syncMonitor` is passed in from `TaskListView`'s sheet presentation. -- **Appearance override**: Uses `UIWindow.overrideUserInterfaceStyle` (set via `onChange(of: appearanceMode, initial: true)` in `ListlessiOSApp`) rather than `.preferredColorScheme()`, because `.preferredColorScheme(nil)` does not properly revert from a non-nil value in sheets. The UIKit window-level override affects all presented view controllers including sheets, and `.unspecified` correctly reverts to the system setting. -- **Focus guard for sheets**: The `onChange(of: focusedFieldBinding)` handler in `TaskListView` skips the "reclaim focus to `.scrollView`" logic when `isShowingSettings` or `isShowingSyncDiagnostics` is true, preventing the focus machinery from stealing focus from TextFields in presented sheets. -- **App icon in About screen**: `Media.xcassets/AboutIcon.imageset` contains a copy of the app icon as a regular image set, because `.appiconset` images cannot be loaded via `Image("AppIcon")` in SwiftUI. Reference it as `Image("AboutIcon")`. -- **iOS color system**: `ListlessiOS/Helpers/AppColors.swift` defines `Color.outerBackground` and `Color.taskCard` as adaptive `UIColor`-backed colors (warm gray/black outer; white/dark-gray card). Adjust these two values to shift the overall palette. -- **iOS card layout**: Active task rows use `UnevenRoundedRectangle` — square leading corners (cards extend flush to the left screen edge, no leading margin) and 14pt trailing corners. Completed rows have no card background and sit directly on `outerBackground`. VStack spacing (12pt) and padding (trailing + vertical) are set directly in `ListlessiOS/Views/TaskListView.swift`; the macOS `TaskListView.swift` uses 0 spacing and no padding. -- **Gradient accent bars**: Each active row has an 8pt `Rectangle` on the leading edge filled with `cachedAccentColor` — a gradient interpolated across HSB color stops (coral → magenta → purple-blue) based on `index`/`totalTasks`. Color is computed once on appear and recomputed via `onChange(of: "\(index)-\(totalTasks)")`. The same gradient is used as the swipe-right reveal color via the `completeColor` parameter on `taskSwipeGesture`. The color stops and interpolation logic are identical to the macOS version in `ListlessMac/Views/TaskRowView.swift`. The shared interpolation function lives in `Listless/Helpers/AccentColor.swift`. -- **iOS drag-and-drop**: Long-press (0.4s) begins a drag; the row stays in-place but receives `scaleEffect(1.03)` + shadow + elevated `zIndex` — no floating overlay or ghost spacer. Implemented with pure SwiftUI `LongPressGesture(minimumDuration: 0.4).sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .global))` via `.simultaneousGesture()`; `.scrollDisabled(draggedTaskID != nil)` prevents the ScrollView from stealing touches. `rowFrames: [UUID: CGRect]` tracks each row's global frame via `.onGeometryChange`; when the finger moves past 20% of the dragged row's height beyond its own edge the row swaps with its immediate neighbour (spring animation); `visualOrder: [UUID]?` holds the in-progress order. `commitIOSDrag()` calls `store.moveTask()` on release. The macOS `taskDragGesture` extension accepts the same parameters with no-op defaults to keep the shared call-site signature. -- **Pull-to-create**: Pulling down past the top of the scroll view (iOS only) creates a new task at the beginning of the active list. Implemented with `onScrollGeometryChange` (extracts `max(0, -(geo.contentOffset.y + geo.contentInsets.top))` as the pull distance — goes positive during rubber-band overscroll) and `onScrollPhaseChange` (triggers task creation when transitioning out of `.interacting`). A `sensoryFeedback` modifier with a `!old && new` condition fires haptic once when the 70pt threshold is first crossed. The visual indicator (`PullToCreateIndicator` in `ListlessiOS/Views/PullToCreate.swift`) grows in the VStack between `navigationHeader` and the first task row via the `pullToCreateIndicatorRow` extension property in `ListlessiOS/Extensions/TaskListView+PullToCreate.swift`. All of these — the scroll modifiers, `sensoryFeedback`, and `pullToCreateIndicatorRow` call — live directly in the iOS `body` in `ListlessiOS/Views/TaskListView.swift`; the macOS `body` omits them entirely so no macOS stub is needed. The header stays fixed during the pull gesture via `.offset(y: -pullOffset)` applied to the entire VStack — this counteracts the ScrollView's rubber-band displacement exactly, so the header appears stationary while the indicator grows beneath it; during normal scrolling the header scrolls with the list as usual. - - **Indicator state**: `createIndicatorOffset` (separate from `pullOffset`) only updates while `isScrollInteracting` is true, so the indicator doesn't collapse during the rubber-band snapback after the user lifts their finger. `isCreateInsertionPending` latches to `true` on release when the threshold is met, keeping the indicator visible and pinned at `pullCreateThreshold` height until the new task actually appears. `activeTaskCountBeforeCreate` captures `activeTasks.count` immediately before calling `createNewTaskAtTop()`. An `onChange(of: activeTasks.count)` observer waits until `newCount > activeTaskCountBeforeCreate`, then clears both `isCreateInsertionPending` and `createIndicatorOffset` atomically with `disablesAnimations: true` — this removes the indicator in the same render pass that the ForEach inserts the new task row, eliminating the layout-shift blink that would otherwise occur. The `pullToCreateIndicatorRow` `@ViewBuilder` shows whenever `createIndicatorOffset > 0 || isCreateInsertionPending`. +- **Platform-specific inits**: iOS and macOS `TaskRowView` have diverged (iOS takes `isDragging: Binding<Bool>`). When adding new parameters, update both `TaskRowView` inits and both `TaskListView` bodies. +- **Appearance override**: Use `UIWindow.overrideUserInterfaceStyle` in `ListlessiOSApp`, not `.preferredColorScheme()` (which doesn't properly revert to nil in sheets). +- **Focus guard for sheets**: The `onChange(of: focusedFieldBinding)` handler skips "reclaim focus to `.scrollView`" logic when a sheet is presented, preventing focus theft from sheet TextFields. +- **App icon in About screen**: Use `Image("AboutIcon")` from `Media.xcassets/AboutIcon.imageset` — `.appiconset` images can't be loaded via `Image()` in SwiftUI. +- **iOS color system**: `ListlessiOS/Helpers/AppColors.swift` defines `Color.outerBackground` and `Color.taskCard`. Adjust these two values to shift the palette. +- **Pull-to-create/clear**: Scroll gesture handling is in `.pullCreationGesture()` (`TaskListView+PullGestures.swift`); visual indicators are in `TaskListView+PullToCreate.swift` and `TaskListView+PullToClear.swift`. The macOS `body` omits all of these. ## SwiftUI Implementation Notes -- **TaskListView architecture**: The struct is declared separately in `ListlessiOS/Views/TaskListView.swift` and `ListlessMac/Views/TaskListView.swift`, each with its own platform-specific `body`. Both conform to `TaskListViewProtocol` (defined in `Listless/Helpers/TaskListViewProtocol.swift`), which declares the shared property contract. Both group state by concern using `fState` (focus), `iState` (interaction), and `tState` (task/view-local), with compatibility computed properties preserving existing shared extension APIs during migration. All shared business logic — computed properties, task CRUD, focus management, keyboard navigation, drag state — lives in `Listless/Extensions/TaskListView+Logic.swift` as an extension on `TaskListViewProtocol` (not the concrete struct), so SourceKit resolves it unambiguously. Because `private` in Swift is file-scoped, stored properties accessed from the extension must be declared without the `private` modifier (i.e. `internal`). Platform-specific body helpers follow the same extension pattern already used for `platformToolbar` and `navigationHeader`; macOS extensions that would return `EmptyView()` are simply omitted — the macOS `body` just doesn't call those properties. -- **Selection pattern**: TaskListView owns `@State var selectedTaskID`, children receive `isSelected: Bool` + `onSelect: () -> Void` callback - - Computed Bool pattern prevents SwiftUI ForEach update issues that occur when passing @Binding directly to children -- **Focus management**: Uses single `@FocusState` enum for unified focus management - - `FocusField` enum (top-level, in `Listless/Helpers/TaskListTypes.swift`) with cases: `.task(UUID)` for TextFields, `.scrollView` for navigation mode - - Single source of truth prevents coordination issues; never use multiple @FocusState variables for related focus - - TextField auto-focuses on click (native), `onChange(of: focusedField)` triggers selection - - Keyboard handlers return `.ignored` when wrong focus state to allow event propagation - - **Pending focus resolution**: When creating a new task (e.g., Return on last task), use both `pendingFocus` and direct `focusedField` assignment - - Set both `pendingFocus = .task(newTaskID)` AND `focusedField = .task(newTaskID)` in `createNewTask()` - - Direct `focusedField` assignment: for `TappableTextField` (iOS) where `textFieldShouldReturn` returns `false` — `focusedField` never goes nil, so SwiftUI transfers first responder atomically in the same render pass - - `pendingFocus` fallback: for the background-tap flow where `focusedField` is subsequently forced to nil; `onChange(of: focusedField)` resolves it when nil is detected - - Do NOT try to resolve in `.onAppear` (timing-dependent, causes race conditions) - - Clear `pendingFocus = nil` in `startEditing()` once the field is live, preventing stale resolution - - Guard `deleteIfEmpty()` against deleting tasks that match `pendingFocus` -- **Background tap handling**: ScrollView has `onTapGesture` + `contentShape(Rectangle())` to capture whitespace taps; macOS rows use a plain `onTapGesture`; iOS rows use `.onTapGesture` — neither uses `highPriorityGesture` -- **Text display/editing (iOS)**: Uses always-present `TappableTextField` (UIViewRepresentable wrapping `UITextView`) - - `UITextView` with `isScrollEnabled = false` and `sizeThatFits` expands vertically to wrap text across multiple lines; `textContainerInset = .zero` and `lineFragmentPadding = 0` remove its default internal padding - - `UITextViewDelegate` callbacks (`textViewDidBeginEditing`, `textViewDidEndEditing`) drive `onStartEdit`/`onEndEdit` directly — no `onChange(of: focusedField)` needed in `TaskRowView` - - Return key intercepted in `textView(_:shouldChangeTextIn:replacementText:)` — returns `false` for `"\n"` to block newline insertion while calling `onEditingChanged(false, true)`; UIKit never auto-resigns, keeping keyboard visible during first-responder transfer to new rows - - `onEditingChanged` callbacks from UIKit may arrive during a SwiftUI update pass; if they need to mutate SwiftUI state, defer via `DispatchQueue.main.async` to avoid "Modifying state during view update" warnings - - Applied with `.focused($focusedField, equals: .task(taskID))` — SwiftUI's focus machinery handles `becomeFirstResponder()`/`resignFirstResponder()` automatically - - `returnKeyPressed` flag prevents double-calling `onEditingChanged` when `textViewDidEndEditing` fires after the first-responder transfer - - Placeholder "Enter task" implemented as a `UILabel` subview (UITextView has no native placeholder property); shown/hidden based on text emptiness -- **Text display/editing (macOS)**: Uses always-present `ClickableTextField` (NSViewRepresentable) - - Custom `ClickableNSTextField` subclass overrides `becomeFirstResponder()` to detect clicks - - Coordinator bridges AppKit → SwiftUI via two complementary paths: `becomeFirstResponder` → `onEditingChanged(true)` for immediate row highlighting on click; `controlTextDidBeginEditing` → `onEditingChanged(true)` for programmatic focus (e.g. after task creation) where the mouse-click check doesn't fire. A `hasNotifiedEditingStarted` flag prevents double-notification when both paths trigger. - - Dynamic sizing via `sizeThatFits()`: compact when not editing, full width when editing (supports 1-5 lines wrapping) - - Return key resigns first responder (prevents newline insertion), triggering `controlTextDidEndEditing` → `onEditingChanged(false)` - - Unified behavior: Return and focus loss do the same thing (no separate onSubmit callback) - - Coordinator pattern: holds @Binding and closures, updates SwiftUI state from AppKit delegate methods -- **Drag-and-drop reordering**: Both platforms maintain `visualOrder` during drag for live preview and commit to Core Data via `store.moveTask()` on release; keyboard navigation uses `displayActiveTasks` (visual order during drag, data order otherwise) - - **macOS**: `onDrag` + `dropDestination` with `isTargeted` callbacks; three-zone overlay on each row (1/6 top, 2/3 middle, 1/6 bottom) using `.layoutPriority()` for proportional sizing; top zone always inserts before, bottom always after, middle uses smart direction logic; VStack catch-all `dropDestination` handles the actual drop; ScrollView-level `.onDrop` catch-all clears drag state when a drag is cancelled without a successful drop; 1×1 transparent drag preview; visual lift (scale + shadow + zIndex) triggers on hold (0.4s) via `liftedTaskID` before the drag session begins — `LongPressGesture` in `TaskRowDragGesture` fires `onLift`, an `NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp)` monitor clears the lift on release without drag; once `.onDrag` fires, `draggedTaskID` takes over and `liftedTaskID` is cleared; `isRowLifted()` combines both states for the visual modifiers; note that `leftMouseUp` does not fire during system drag sessions (macOS enters a modal event loop), so cancelled-drag cleanup relies on the ScrollView catch-all `.onDrop`; all of this lives directly in the macOS `body` in `ListlessMac/Views/TaskListView.swift` and `ListlessMac/Helpers/TaskRowDragGesture.swift` - - **iOS**: `LongPressGesture(minimumDuration: 0.4).sequenced(before: DragGesture(...))` via `.simultaneousGesture()`; dragged row stays in-place with scale/shadow/zIndex lift (no overlay or ghost spacer); row frames tracked via `.onGeometryChange` (global coords); neighbour-swap when finger crosses 20% of row height past its own edge; `visualOrder` updated with spring animation; `handleIOSDragChanged` and `commitIOSDrag` live in `ListlessiOS/Extensions/TaskListView+Drag.swift`; `didStartDrag()` (sets `isDragging`, triggers haptic) is defined on the iOS struct in `ListlessiOS/Views/TaskListView.swift` with an empty no-op counterpart on the macOS struct -- **Keyboard navigation system (macOS)**: Dictionary-based keybindings in `Listless/Helpers/KeyboardNavigationModifier.swift` - - `ShortcutKey` struct combines `KeyEquivalent` + `EventModifiers` for flexible shortcuts - - Manual Hashable conformance required (EventModifiers doesn't auto-conform) - - Key normalization: backspace character `\u{7F}` → `.delete` for consistent matching - - Modifier normalization: strips `.function` and `.numericPad` (system modifiers that come with arrow keys) - - Only user-intentional modifiers (.command, .shift, .option, .control) are matched - - Supports modifier-based shortcuts like `ShortcutKey(key: "n", modifiers: .command)` for future ⌘N -- **Keyboard navigation system (iOS)**: `KeyCommandBridge` (`ListlessiOS/Helpers/KeyCommandBridge.swift`) — a `UIViewRepresentable` wrapping a `UIView` that becomes first responder and registers `UIKeyCommand` entries (Up, Down, Space, Return, Delete) with `wantsPriorityOverSystemBehavior = true`. This bypasses iPadOS's `@FocusState` limitation where programmatic focus assignment to a `.focusable()` view silently fails with a hardware keyboard, causing `.onKeyPress` to never fire. The bridge is active when not editing a task (`focusedFieldBinding` is not `.task(...)`); `fState.focusedField` is set to `.scrollView` directly in `onAppear` so navigation function guards pass. The iOS `TaskListView` body does **not** use `.focusable()`, `.focused($focusedFieldBinding, equals: .scrollView)`, or `.keyboardNavigation()` — those remain on macOS only. -- Avoid `Spacer` inside `ScrollView` (causes unwanted scrollbar when content fits viewport). -- **Escape hatches** (`GeometryReader`, `PreferenceKey` size reporting, `UIViewRepresentable`/`NSViewRepresentable`): only reach for these after exhausting SwiftUI-native alternatives. They add complexity, can cause layout loops, and make state flow harder to follow. If you find yourself about to use one, first try `.frame`, `.overlay`, `.background`, `alignmentGuide`, or a different view decomposition. +- **TaskListView architecture**: Declared separately per platform (`ListlessiOS/Views/TaskListView.swift`, `ListlessMac/Views/TaskListView.swift`), both conforming to `TaskListViewProtocol`. State is grouped by concern: `fState` (focus), `iState` (interaction), `tState` (task/view-local). Shared logic lives in `TaskListView+Logic.swift` as an extension on `TaskListViewProtocol`. Because `private` is file-scoped, stored properties accessed from extensions must be `internal`. Platform-specific extensions that would return `EmptyView()` on the other platform are simply omitted. +- **Selection pattern**: Parent owns `@State var selectedTaskID`; children receive `isSelected: Bool` + `onSelect: () -> Void` callback (avoids SwiftUI ForEach update issues with @Binding). +- **Focus management**: Single `@FocusState` enum (`FocusField` in `TaskListTypes.swift`) with `.task(UUID)` and `.scrollView` cases. Never use multiple @FocusState variables for related focus. Keyboard handlers return `.ignored` when wrong focus state. + - **Pending focus**: Set both `pendingFocus` and `focusedField` in `createNewTask()`. Do NOT resolve in `.onAppear` (race conditions). Clear `pendingFocus` in `startEditing()`. Guard `deleteIfEmpty()` against `pendingFocus` matches. +- **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`. +- **Drag-and-drop**: Both platforms maintain `visualOrder` during drag and commit via `store.moveTask()` on release. macOS uses `onDrag` + `dropDestination`; iOS uses `LongPressGesture.sequenced(before: DragGesture)` via `.simultaneousGesture()`. +- **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`). ## Commit & Pull Request Guidelines - Do not create commits; the user handles version control.