commit 04a23f1982f8771de5dde84ceb8cd2cbe402f175
parent 33e450184c4460d41fab4926c06e4b01ac76c18e
Author: Michael Camilleri <[email protected]>
Date: Thu, 5 Mar 2026 06:21:05 +0900
Simplify wording of AGENTS.md
Diffstat:
| M | AGENTS.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.