commit 75899cc21604038c10f5f3b48d8e6cb0b6f1fb1a
parent 990b6983bc24eb8af498c015ed02ebe2a6698a47
Author: Michael Camilleri <[email protected]>
Date: Sun, 1 Mar 2026 09:24:36 +0900
Expose AGENTS.md file
Diffstat:
| M | .gitignore | | | 1 | - |
| A | AGENTS.md | | | 167 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
2 files changed, 167 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
@@ -2,7 +2,6 @@
.cache/
.DS_Store
*.xcuserstate
-AGENTS.md
CLAUDE.md
Generated/
examples/
diff --git a/AGENTS.md b/AGENTS.md
@@ -0,0 +1,167 @@
+# Repository Guidelines
+
+## 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/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`).
+ - `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`).
+- `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.
+
+## Build, Test, and Development Commands
+- `xed .` launches the project in Xcode.
+- `xcodegen generate` regenerates `Listless.xcodeproj` from `project.yml`.
+- `xcodebuild -scheme "Listless macOS" -destination 'platform=macOS' build` builds the native macOS app.
+- `xcodebuild -scheme "Listless iOS" -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.6' build` builds the iOS app.
+- `xcodebuild test -scheme "Listless iOS" -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.6'` runs unit + UI tests.
+- `swift format lint --recursive .` must be clean before opening a PR.
+
+## TestFlight Release (asc CLI)
+- Use `asc` for App Store Connect/TestFlight automation.
+- In this repo, `asc` auth is stored in `./.asc/config.json` and keychain lookups may fail; run commands with `ASC_BYPASS_KEYCHAIN=1`.
+- Listless app identifier in App Store Connect: `6759801710` (bundle ID `net.inqk.listless`).
+- Internal beta group currently used: `Personal` (`bc458e15-2743-471c-a16d-c25fb88e07de`).
+- Build numbers come from scheme pre-action (`YEAR.COMMIT_COUNT`), so archiving from latest `HEAD` produces the latest-commit build.
+
+### One-time auth setup
+- `asc auth login --bypass-keychain --local --name "listless-asc-cli" --key-id "<KEY_ID>" --issuer-id "<ISSUER_ID>" --private-key "./Generated/AuthKey_<KEY_ID>.p8"`
+- Verify: `ASC_BYPASS_KEYCHAIN=1 asc apps list --limit 5 --output table`
+
+### Build latest-commit IPA
+- `xcodebuild -scheme "Listless iOS" -project Listless.xcodeproj -configuration Release -destination 'generic/platform=iOS' -archivePath /tmp/Listless-latest.xcarchive archive`
+- Write export options plist at `/tmp/Listless-ExportOptions.plist` with:
+ - `method=app-store`
+ - `signingStyle=automatic`
+ - `destination=export`
+ - `stripSwiftSymbols=true`
+ - `manageAppVersionAndBuildNumber=false`
+- Export IPA:
+ - `xcodebuild -exportArchive -archivePath /tmp/Listless-latest.xcarchive -exportPath /tmp/Listless-export -exportOptionsPlist /tmp/Listless-ExportOptions.plist`
+- IPA path: `/tmp/Listless-export/Listless iOS.ipa`
+
+### Publish to internal TestFlight
+- `ASC_BYPASS_KEYCHAIN=1 asc publish testflight --app 6759801710 --ipa '/tmp/Listless-export/Listless iOS.ipa' --group bc458e15-2743-471c-a16d-c25fb88e07de --wait --output table`
+- Do not pass `--notify` for this internal-group flow (ASC rejects it).
+- Do not rely on manual internal group assignment commands for this group; internal availability is reflected in build beta state.
+
+### Verify upload/distribution state
+- Latest build: `ASC_BYPASS_KEYCHAIN=1 asc builds latest --app 6759801710 --output table`
+- Specific build: `ASC_BYPASS_KEYCHAIN=1 asc builds find --app 6759801710 --build-number "<YEAR.COMMIT_COUNT>" --output table`
+- Internal state: `ASC_BYPASS_KEYCHAIN=1 asc builds build-beta-detail get --build "<BUILD_ID>" --output json --pretty`
+- Success criterion for internal TestFlight: `"internalBuildState": "IN_BETA_TESTING"`.
+
+## Build Number
+- `CFBundleVersion` in both Info.plist files uses `$(CURRENT_PROJECT_VERSION)`, sourced from `Generated/BuildNumber.xcconfig`.
+- Each scheme has a build pre-action that runs `git log`/`git rev-list` to compute `YEAR.COMMIT_COUNT` and writes it to the xcconfig before the build starts.
+- Scheme pre-actions are not subject to `ENABLE_USER_SCRIPT_SANDBOXING`, which is why this lives in the scheme rather than a build phase script.
+- `Generated/BuildNumber.xcconfig` is gitignored; the scheme pre-action creates it before every build.
+
+## Coding Style & Naming Conventions
+- Use SwiftUI + Combine, indent four spaces, and prefer trailing commas in builders.
+- Models are nouns (`TaskItem`), views end with `View`, and services end with `Service`; keep async methods verb-first (`syncTasks()`).
+- Centralize state in `@Observable TaskStore` that wraps Core Data operations; mutations flow through intent methods like `complete(taskID:)`.
+- `TaskStore.createTask(title:atBeginning:)` accepts an `atBeginning` flag (default `false`); when `true` assigns `minSortOrder - 1000` to prepend the task before all existing active tasks.
+- Completed tasks 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).
+
+## Sync & Data Guidelines
+- Core Data with `NSPersistentCloudKitContainer` handles persistence and iCloud sync; configured in `PersistenceController` within `Listless/Sync`.
+- `TaskItem` is an `NSManagedObject` subclass 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.
+- 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
+- Use Swift Testing framework with `@Test` macro and `#expect` assertions.
+- Organize tests into `@Suite` groupings with descriptive names.
+- Use natural function names that describe what's being tested (no `test` prefix required).
+- Leverage parameterized tests with `@Test(arguments: [...])` for testing multiple scenarios.
+- Maintain ≥80% coverage for `TaskStore` and Core Data operations, especially reordering edge cases and merge conflict resolution.
+- UI tests must verify keyboard entry flows: pressing Return creates a task then focuses the new empty row; tapping whitespace also starts entry.
+- Use `PersistenceController(inMemory: true)` for isolated test environments with Core Data.
+
+## macOS Implementation: SwiftUI vs AppKit
+- **Current implementation**: macOS uses SwiftUI (same as iOS)
+- **AppKit alternative**: A complete AppKit implementation exists in `ListlessMac/AppKit/` but is excluded from builds
+ - Attempted migration in Feb 2026 for better drag-and-drop control
+ - Reverted due to state management complexity, focus issues, and selection bugs
+ - SwiftUI's declarative patterns proved more maintainable for this app
+- **Switching to AppKit** (if needed): Update `project.yml` to exclude SwiftUI views and include `ListlessMac/AppKit/`, then run `xcodegen generate`
+
+## macOS Menu Customisation
+- **Do not use SwiftUI's `Commands` API** — broken on macOS 15: dividers don't render and `CommandGroup(replacing:)` causes Window menu jitter.
+- **Menus are fully AppKit-owned** in `AppDelegate.installMainMenu()` (`ListlessMac/ListlessMacApp.swift`), assigned directly via `NSApp.mainMenu`.
+- **No runtime menu patching**: do not use `NSMenu.didAddItemNotification`/tag-guard patch logic for command setup in the current architecture.
+- **`MenuCoordinator`** (`ListlessMac/Helpers/AppCommands.swift`) bridges SwiftUI state to AppKit: action closures + enabled-state booleans updated by `TaskListView.updateMenuCoordinator()`. Enabled state is surfaced via `NSMenuItemValidation.validateMenuItem` on `AppDelegate`.
+- **Command shortcuts are canonical in AppKit menus** (e.g. New Task, Move Up/Down, Mark Completed, Delete). Avoid duplicating those command shortcuts in `TaskListView.keyboardNavigation(...)`.
+- **Selector style**: prefer typed `#selector(...)` where available; use `MenuSelectors` constants for string-based selectors that lack typed Swift symbols.
+- **Window menu**: keep explicit baseline items in `installMainMenu()` and let `NSApp.windowsMenu` provide system-managed dynamic window list behavior.
+
+## 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 ("Tasks") 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.
+- **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`.
+
+## 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**: 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
+- 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.
+
+## Commit & Pull Request Guidelines
+- Do not create commits; the user handles version control.
+- PR descriptions should summarize scope, list test commands, link issues, and attach screenshots or screen recordings for UI changes.
+- Call out Core Data model or CloudKit schema updates explicitly and describe any expected migration impact.