@horka/app-forge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +99 -0
  3. package/bin/cli.js +371 -0
  4. package/bin/cli.test.js +91 -0
  5. package/package.json +43 -0
  6. package/templates/core/CLAUDE.md +36 -0
  7. package/templates/core/claude/memory/ARCHITECTURE.md +20 -0
  8. package/templates/core/claude/memory/COMMANDS.md +13 -0
  9. package/templates/core/claude/memory/DECISIONS.md +5 -0
  10. package/templates/core/claude/memory/NEXT_STEPS.md +11 -0
  11. package/templates/core/claude/memory/PROJECT_STATE.md +24 -0
  12. package/templates/core/claude/skills/kickoff/SKILL.md +84 -0
  13. package/templates/core/claude/skills/product-owner/SKILL.md +58 -0
  14. package/templates/core/claude/skills/restore-context/SKILL.md +29 -0
  15. package/templates/core/claude/skills/save-context/SKILL.md +35 -0
  16. package/templates/core/docs-architecture/ANTI_PATTERNS.md +180 -0
  17. package/templates/core/docs-architecture/ARCHITECTURE_PRINCIPLES.md +134 -0
  18. package/templates/core/docs-architecture/DELIVERY.md +68 -0
  19. package/templates/core/docs-architecture/DOCS_PLACEMENT.md +151 -0
  20. package/templates/core/docs-architecture/MULTI_REPO_CONTRACT.md +158 -0
  21. package/templates/core/docs-architecture/SDK_CONTRACT.md +214 -0
  22. package/templates/core/docs-architecture/SECURITY_USER_URLS.md +152 -0
  23. package/templates/core/gitignore +15 -0
  24. package/templates/core/mcp.json +8 -0
  25. package/templates/packs/nuxt-web/CLAUDE.md +74 -0
  26. package/templates/packs/nuxt-web/app/app.vue +5 -0
  27. package/templates/packs/nuxt-web/app/assets/css/main.css +18 -0
  28. package/templates/packs/nuxt-web/app/assets/css/tokens.css +41 -0
  29. package/templates/packs/nuxt-web/app/designSystem/DSButton/components/DSButton.vue +70 -0
  30. package/templates/packs/nuxt-web/app/designSystem/DSButton/index.ts +4 -0
  31. package/templates/packs/nuxt-web/app/designSystem/DSButton/tests/DSButton.spec.ts +34 -0
  32. package/templates/packs/nuxt-web/app/designSystem/DSButton/types/dsButton.ts +5 -0
  33. package/templates/packs/nuxt-web/app/domain/.gitkeep +0 -0
  34. package/templates/packs/nuxt-web/app/features/.gitkeep +0 -0
  35. package/templates/packs/nuxt-web/app/pages/index.vue +36 -0
  36. package/templates/packs/nuxt-web/app/utils/.gitkeep +0 -0
  37. package/templates/packs/nuxt-web/claude/memory/COMMANDS.md +21 -0
  38. package/templates/packs/nuxt-web/docs-architecture/ARCHITECTURE.md +169 -0
  39. package/templates/packs/nuxt-web/docs-architecture/CONVENTIONS.md +140 -0
  40. package/templates/packs/nuxt-web/docs-architecture/I18N.md +102 -0
  41. package/templates/packs/nuxt-web/docs-architecture/OPS_WEB.md +176 -0
  42. package/templates/packs/nuxt-web/docs-architecture/SEO_AND_ROUTING.md +118 -0
  43. package/templates/packs/nuxt-web/gitignore +18 -0
  44. package/templates/packs/nuxt-web/nuxt.config.ts +49 -0
  45. package/templates/packs/nuxt-web/pack.json +11 -0
  46. package/templates/packs/nuxt-web/package.json +31 -0
  47. package/templates/packs/nuxt-web/playwright.config.ts +39 -0
  48. package/templates/packs/nuxt-web/server/api/health.get.ts +7 -0
  49. package/templates/packs/nuxt-web/tests/e2e/home.spec.ts +19 -0
  50. package/templates/packs/nuxt-web/tsconfig.json +4 -0
  51. package/templates/packs/nuxt-web/vitest.config.ts +23 -0
  52. package/templates/packs/swift-ios/CLAUDE.md +64 -0
  53. package/templates/packs/swift-ios/Packages/DataLayer/Package.swift +21 -0
  54. package/templates/packs/swift-ios/Packages/DataLayer/Sources/DataLayer/DataLayer.swift +11 -0
  55. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Package.swift +20 -0
  56. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Domain/SampleItem.swift +15 -0
  57. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Engine/SampleEngine.swift +14 -0
  58. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Repository/SampleItemRepository.swift +27 -0
  59. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Tests/{{PROJECT_NAME}}CoreTests/SampleEngineTests.swift +32 -0
  60. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Package.swift +17 -0
  61. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Color+DS.swift +18 -0
  62. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Components/DSCard.swift +22 -0
  63. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DS.swift +36 -0
  64. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DSFont.swift +26 -0
  65. package/templates/packs/swift-ios/claude/memory/COMMANDS.md +18 -0
  66. package/templates/packs/swift-ios/docs-architecture/ARCHITECTURE.md +246 -0
  67. package/templates/packs/swift-ios/docs-architecture/CLOUDKIT_GUIDE.md +224 -0
  68. package/templates/packs/swift-ios/docs-architecture/CONVENTIONS.md +246 -0
  69. package/templates/packs/swift-ios/docs-architecture/DESIGN_SYSTEM.md +272 -0
  70. package/templates/packs/swift-ios/docs-architecture/NAVIGATION.md +241 -0
  71. package/templates/packs/swift-ios/docs-architecture/TESTING.md +176 -0
  72. package/templates/packs/swift-ios/docs-architecture/WORKFLOW.md +165 -0
  73. package/templates/packs/swift-ios/github/workflows/ci.yml +48 -0
  74. package/templates/packs/swift-ios/gitignore +5 -0
  75. package/templates/packs/swift-ios/mcp.json +8 -0
  76. package/templates/packs/swift-ios/pack.json +11 -0
  77. package/templates/packs/swift-ios/project.yml +33 -0
  78. package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/App.swift +32 -0
  79. package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/AppNamespace.swift +4 -0
  80. package/templates/packs/swift-ios/{{PROJECT_NAME}}/Module/.gitkeep +0 -0
  81. package/templates/packs/swift-ios/{{PROJECT_NAME}}/Store/.gitkeep +0 -0
  82. package/templates/packs/swift-ios/{{PROJECT_NAME}}/Tools/.gitkeep +0 -0
  83. package/templates/packs/ts-sdk/CHANGELOG.md +9 -0
  84. package/templates/packs/ts-sdk/CLAUDE.md +72 -0
  85. package/templates/packs/ts-sdk/MIGRATION.md +28 -0
  86. package/templates/packs/ts-sdk/claude/memory/COMMANDS.md +21 -0
  87. package/templates/packs/ts-sdk/docs-architecture/ARCHITECTURE.md +132 -0
  88. package/templates/packs/ts-sdk/docs-architecture/CONVENTIONS_TS.md +152 -0
  89. package/templates/packs/ts-sdk/gitignore +6 -0
  90. package/templates/packs/ts-sdk/pack.json +11 -0
  91. package/templates/packs/ts-sdk/package.json +55 -0
  92. package/templates/packs/ts-sdk/scripts/verify-dist.mjs +67 -0
  93. package/templates/packs/ts-sdk/src/clients/AuthClient.ts +168 -0
  94. package/templates/packs/ts-sdk/src/core/HttpClient.ts +85 -0
  95. package/templates/packs/ts-sdk/src/core/Logger.ts +27 -0
  96. package/templates/packs/ts-sdk/src/core/SDKContext.ts +40 -0
  97. package/templates/packs/ts-sdk/src/core/withTimeout.ts +19 -0
  98. package/templates/packs/ts-sdk/src/errors/ApiError.ts +93 -0
  99. package/templates/packs/ts-sdk/src/index.ts +62 -0
  100. package/templates/packs/ts-sdk/src/types/index.ts +33 -0
  101. package/templates/packs/ts-sdk/tests/apiError.test.ts +58 -0
  102. package/templates/packs/ts-sdk/tests/httpClient.test.ts +60 -0
  103. package/templates/packs/ts-sdk/tests/singleFlight.test.ts +191 -0
  104. package/templates/packs/ts-sdk/tsconfig.json +15 -0
  105. package/templates/packs/ts-sdk/tsup.config.ts +22 -0
  106. package/templates/packs/ts-sdk/vitest.config.ts +8 -0
  107. package/templates/packs/vapor-api/CLAUDE.md +73 -0
  108. package/templates/packs/vapor-api/Dockerfile +80 -0
  109. package/templates/packs/vapor-api/Package.swift +68 -0
  110. package/templates/packs/vapor-api/Sources/App/App.swift +5 -0
  111. package/templates/packs/vapor-api/Sources/App/Configure/AppConfig.swift +108 -0
  112. package/templates/packs/vapor-api/Sources/App/Configure/configure.swift +74 -0
  113. package/templates/packs/vapor-api/Sources/App/Configure/entrypoint.swift +47 -0
  114. package/templates/packs/vapor-api/Sources/App/Configure/routes.swift +21 -0
  115. package/templates/packs/vapor-api/Sources/App/Error/Failed.swift +73 -0
  116. package/templates/packs/vapor-api/Sources/App/Error/FailedMiddleware.swift +56 -0
  117. package/templates/packs/vapor-api/Sources/App/Features/Item/AppItem.swift +38 -0
  118. package/templates/packs/vapor-api/Sources/App/Features/Item/Controllers/ItemControllersCrud.swift +41 -0
  119. package/templates/packs/vapor-api/Sources/App/Features/Item/DTO/ItemDTO.swift +22 -0
  120. package/templates/packs/vapor-api/Sources/App/Features/Item/Entities/ItemEntity.swift +30 -0
  121. package/templates/packs/vapor-api/Sources/App/Features/Item/Migrations/ItemMigrationCreate.swift +25 -0
  122. package/templates/packs/vapor-api/Sources/App/Features/Item/Repositories/ItemRepository.swift +32 -0
  123. package/templates/packs/vapor-api/Sources/App/Features/Item/Services/ItemService.swift +57 -0
  124. package/templates/packs/vapor-api/Sources/App/Registry/ControllersRegister.swift +17 -0
  125. package/templates/packs/vapor-api/Sources/App/Registry/MiddlewaresRegister.swift +15 -0
  126. package/templates/packs/vapor-api/Sources/App/Registry/MigrationsRegister.swift +18 -0
  127. package/templates/packs/vapor-api/Sources/Monitoring/Logging/JSONLogHandler.swift +59 -0
  128. package/templates/packs/vapor-api/Sources/Monitoring/Middleware/HTTPLoggingMiddleware.swift +50 -0
  129. package/templates/packs/vapor-api/Sources/Monitoring/Monitoring.swift +110 -0
  130. package/templates/packs/vapor-api/Sources/{{PROJECT_NAME}}Foundation/String+Trimmed.swift +15 -0
  131. package/templates/packs/vapor-api/Tests/AppTests/AppTests.swift +155 -0
  132. package/templates/packs/vapor-api/claude/memory/COMMANDS.md +30 -0
  133. package/templates/packs/vapor-api/docs-architecture/ARCHITECTURE.md +144 -0
  134. package/templates/packs/vapor-api/docs-architecture/CONVENTIONS.md +121 -0
  135. package/templates/packs/vapor-api/docs-architecture/GOTCHAS_LINUX_SWIFT.md +109 -0
  136. package/templates/packs/vapor-api/docs-architecture/OPS.md +102 -0
  137. package/templates/packs/vapor-api/env_dist +29 -0
  138. package/templates/packs/vapor-api/gitignore +7 -0
  139. package/templates/packs/vapor-api/pack.json +11 -0
  140. package/templates/packs/vapor-api/scripts/generate-error-codes.sh +73 -0
  141. package/templates/packs/vapor-api/scripts/validate-env-vars.sh +72 -0
@@ -0,0 +1,241 @@
1
+ # NAVIGATION.md — Navigation Architecture
2
+
3
+ No navigation library, no global Coordinator. Three layers:
4
+ 1. **Root gating** — `RootView` switches on store state (splash → onboarding → tabs).
5
+ 2. **Per-tab `NavigationStack(path:)`** — value-based destinations, path mutated by Interactors.
6
+ 3. **External-event routing** — a dedicated `PushRouter` object owned by the AppDelegate, with a weak store reference and a pending-event queue for cold-launch races.
7
+
8
+ ## 1. Root gating
9
+
10
+ `RootView` is a pure state switch. No `NavigationStack` at the root — stacks live inside tabs.
11
+
12
+ ```swift
13
+ @main
14
+ struct App: SwiftUI.App {
15
+ @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
16
+ @State private var store = ItemStore()
17
+
18
+ var body: some Scene {
19
+ WindowGroup {
20
+ RootView()
21
+ .environment(store)
22
+ .task {
23
+ appDelegate.pushRouter.store = store // wire BEFORE bootstrap
24
+ await store.bootstrap()
25
+ }
26
+ }
27
+ }
28
+ }
29
+
30
+ struct RootView: View {
31
+ @Environment(ItemStore.self) private var store
32
+ @Environment(\.scenePhase) private var scenePhase
33
+
34
+ var body: some View {
35
+ Group {
36
+ if !store.isReady {
37
+ App.Splash() // first load — no flash of empty/default data
38
+ } else if store.needsOnboarding {
39
+ App.Welcome() // first launch — complete profile before entering
40
+ } else {
41
+ tabs
42
+ }
43
+ }
44
+ .onChange(of: scenePhase) { _, phase in
45
+ if phase == .active { Task { await store.handleForeground() } }
46
+ }
47
+ }
48
+
49
+ private var tabs: some View {
50
+ TabView {
51
+ Tab("Feed", systemImage: "house.fill") { App.Feed() } // owns its stack
52
+ Tab("Map", systemImage: "map.fill") { NavigationStack { App.Map() } }
53
+ Tab("Groups", systemImage: "person.2.fill") { App.Groups() } // owns its stack
54
+ Tab("Profile", systemImage: "person.crop.circle.fill") { NavigationStack { App.Profile() } }
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ Rules:
61
+ - Gate on `store.isReady`, never on "data is empty" — empty is a valid state, loading is not.
62
+ - Tabs that push destinations own their `NavigationStack` *inside* the tab view (so the path lives in that tab's ViewModel). Tabs that never push get a plain `NavigationStack { }` wrapper in `RootView` for the nav bar.
63
+ - App-wide errors surface as one alert on `RootView` bound to `store.lastError`.
64
+
65
+ > ⚠️ **Gotcha:** an alert attached to the presenter cannot appear over a presented sheet. Symptom: errors thrown from a sheet's save action silently never show. Cause: SwiftUI presents one thing per branch; the root alert is occluded by the sheet. Fix: sheets that can fail keep their **own** local alert.
66
+
67
+ ## 2. Per-tab stacks: typed paths + Interactor-driven navigation
68
+
69
+ Each tab follows View / ViewModel / Interactor. The **path is state on the ViewModel**; the **Interactor mutates it**. Views never use raw `NavigationLink` to other features and never know sibling screens exist.
70
+
71
+ ```swift
72
+ extension App.Feed {
73
+ @Observable
74
+ final class ViewModel {
75
+ /// Navigation path for this tab's stack — driven by the Interactor.
76
+ var path: [ItemGroup] = []
77
+ /// Drives the anchored popover for the tapped badge.
78
+ var selectedBadgeID: String?
79
+ }
80
+
81
+ struct Interactor {
82
+ let store: ItemStore
83
+ let viewModel: ViewModel
84
+
85
+ /// Navigation goes through the Interactor (no NavigationLink in the View).
86
+ func openGroup(_ group: ItemGroup) {
87
+ viewModel.path.append(group)
88
+ }
89
+ }
90
+ }
91
+
92
+ extension App.Feed {
93
+ var body: some View {
94
+ NavigationStack(path: $viewModel.path) {
95
+ ScrollView { /* sections */ }
96
+ .navigationDestination(for: ItemGroup.self) { App.GroupDetail(group: $0) }
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ - Single destination type per tab → path is `[ItemGroup]` directly. Multiple destination types → declare a tab-local `enum Route: Hashable { case group(ItemGroup), item(Item.ID) }`, path is `[Route]`, one `navigationDestination(for: Route.self)` switch.
103
+ - Pop = `viewModel.path.removeLast()`; pop-to-root = `viewModel.path = []`. Both testable without UI.
104
+ - Cross-tab "navigation" does not exist. If an external event must land on a specific screen, the store exposes state (e.g. `pendingInviteGroupID`) and the owning tab reacts to it — never reach into another tab's path.
105
+ - Name the domain type `ItemGroup`, not `Group`: `Group` collides with `SwiftUI.Group` in every view file.
106
+
107
+ ## 3. External-event routing: `PushRouter`
108
+
109
+ One small `@MainActor` object bridges system callbacks (silent pushes, share/deeplink acceptance) to store refreshes. The AppDelegate owns it; the SwiftUI scene wires the store after launch.
110
+
111
+ ```swift
112
+ @MainActor
113
+ final class PushRouter {
114
+ weak var store: ItemStore? {
115
+ didSet {
116
+ // Cold-launch race: a share accepted before the scene wired the store
117
+ // would otherwise be lost. Flush the queued event once the store connects.
118
+ if store != nil, pendingShareAccept {
119
+ pendingShareAccept = false
120
+ Task { await didAcceptShare() }
121
+ }
122
+ }
123
+ }
124
+ private var pendingShareAccept = false
125
+
126
+ /// Silent CloudKit push → targeted refresh (payload only says "something changed").
127
+ func handle(_ notification: CKNotification) async {
128
+ switch notification.notificationType {
129
+ case .database: await store?.refreshGroups()
130
+ case .recordZone: await store?.refresh()
131
+ default: await store?.refresh(); await store?.refreshGroups()
132
+ }
133
+ }
134
+
135
+ func didAcceptShare() async {
136
+ guard let store else { pendingShareAccept = true; return } // queue, don't drop
137
+ await store.refreshGroups()
138
+ try? await Task.sleep(for: .seconds(2)) // server-side propagation delay
139
+ await store.refreshGroups() // second pass picks up the new shared zone
140
+ }
141
+ }
142
+ ```
143
+
144
+ > ⚠️ **Gotcha (cold-launch race):** symptom — accepting a share invite while the app is cold-launched does nothing; the joined group never appears. Cause — `userDidAcceptCloudKitShareWith` fires *before* the SwiftUI scene runs `.task` and wires `pushRouter.store`, so the event hits a `nil` store and is dropped. Fix — set a `pending` flag in the guard and flush it in `store`'s `didSet` (shown above). Apply this queue-and-flush pattern to **every** external entry point (deeplinks, notification taps).
145
+
146
+ > ⚠️ **Gotcha (propagation delay):** symptom — after accepting a share, the first refresh returns no new data. Cause — the backend needs time to surface the newly shared zone after `accept`. Fix — refresh immediately *and* again after ~2s. Never rely on a single post-accept fetch.
147
+
148
+ ## 4. Scene delegate: required even with SwiftUI lifecycle
149
+
150
+ In a SwiftUI App-lifecycle app, some system callbacks are delivered to the **scene** delegate, not the app delegate — CloudKit share acceptance (`userDidAcceptCloudKitShareWith`) is the canonical example. SwiftUI still creates and owns the window; you only *declare* the delegate class:
151
+
152
+ ```swift
153
+ final class AppDelegate: NSObject, UIApplicationDelegate {
154
+ let pushRouter = PushRouter()
155
+
156
+ /// Declare the scene delegate class. SwiftUI keeps ownership of the window.
157
+ func application(_ application: UIApplication,
158
+ configurationForConnecting session: UISceneSession,
159
+ options: UIScene.ConnectionOptions) -> UISceneConfiguration {
160
+ let config = UISceneConfiguration(name: nil, sessionRole: session.role)
161
+ if session.role == .windowApplication {
162
+ config.delegateClass = SceneDelegate.self
163
+ }
164
+ return config
165
+ }
166
+ }
167
+
168
+ final class SceneDelegate: NSObject, UIWindowSceneDelegate {
169
+ func windowScene(_ windowScene: UIWindowScene,
170
+ userDidAcceptCloudKitShareWith metadata: CKShare.Metadata) {
171
+ let pushRouter = (UIApplication.shared.delegate as? AppDelegate)?.pushRouter
172
+ Task {
173
+ try? await ShareAcceptance.accept(metadata) // metadata is non-Sendable: consume it here
174
+ await pushRouter?.didAcceptShare()
175
+ }
176
+ }
177
+ }
178
+ ```
179
+
180
+ Rules:
181
+ - Do **not** implement `scene(_:willConnectTo:)` — that would take window ownership away from SwiftUI.
182
+ - Implement the share-accept callback in **both** delegates (app delegate gets it on some OS paths, scene delegate on others). Both funnel into the same `PushRouter` method, so duplication is one line.
183
+ - `CKShare.Metadata` is non-Sendable: accept it inside one `@MainActor` helper and only let Sendable results (IDs) escape.
184
+
185
+ > ⚠️ **Gotcha:** symptom — tapped invite links open the app but nothing happens; the app-delegate hook never fires. Cause — with the SwiftUI lifecycle the system delivers share acceptance to the *scene* delegate, and no scene delegate exists by default. Fix — the `configurationForConnecting` + `delegateClass` wiring above. Without it the callback is silently lost.
186
+
187
+ ## 5. Popovers & sheets
188
+
189
+ Anchored detail (map markers, badge cells) uses `.popover` + `.presentationCompactAdaptation(.popover)` (anchored glass popover on iPhone instead of a sheet). One selection at a time via a `selectedID` + derived `Binding<Bool>`:
190
+
191
+ ```swift
192
+ @State private var selectedID: UUID?
193
+
194
+ marker(item)
195
+ .onTapGesture { selectedID = item.id }
196
+ .popover(isPresented: binding(for: item.id)) {
197
+ detail(item).presentationCompactAdaptation(.popover)
198
+ }
199
+
200
+ private func binding(for id: UUID) -> Binding<Bool> {
201
+ Binding(
202
+ get: { selectedID == id },
203
+ set: { isPresented in
204
+ if isPresented { selectedID = id }
205
+ else if selectedID == id { selectedID = nil } // only clear if still ours
206
+ }
207
+ )
208
+ }
209
+ ```
210
+
211
+ In the `set:` closure, guard `selectedID == id` before clearing — a stale dismiss from a previous popover must not wipe a newer selection.
212
+
213
+ > ⚠️ **Gotcha (orphaned popover — real production bug):** symptom — after deleting an item while its popover was open, tapping any other marker did nothing; the map was stuck. Cause — the item vanished from the list but `selectedID` still pointed to it, so SwiftUI kept trying to re-present a popover anchored to a non-existent annotation on every camera change, swallowing new selections. Two-part fix:
214
+ > 1. **Dismiss before mutating** in the detail view's destructive action:
215
+ > ```swift
216
+ > Button("Delete", role: .destructive) {
217
+ > dismiss() // close the popover FIRST so it can't orphan
218
+ > onDelete?()
219
+ > }
220
+ > ```
221
+ > 2. **Prune stale selection** defensively in the container:
222
+ > ```swift
223
+ > .onChange(of: items.map(\.id)) { _, ids in
224
+ > if let selected = selectedID, !ids.contains(selected) { selectedID = nil }
225
+ > }
226
+ > ```
227
+ > Do both. Never mutate a list while a presentation anchored to one of its rows is open.
228
+
229
+ Sheets (create/edit flows) follow the same discipline: a `Bool` or optional-item flag on the ViewModel (`isCreating`, `pendingInvite: GroupShare?`), toggled by the Interactor on success — the View never decides when a flow ends.
230
+
231
+ ## Decision summary
232
+
233
+ | Concern | Pattern |
234
+ |---|---|
235
+ | Root routing | State switch in `RootView` (splash / onboarding / tabs) |
236
+ | In-tab navigation | `NavigationStack(path:)`, path on ViewModel, mutated by Interactor |
237
+ | Cross-feature navigation | Forbidden; react to store state instead |
238
+ | External events | `PushRouter` (weak store + pending queue flushed in `didSet`) |
239
+ | Share/deeplink entry | App delegate **and** scene delegate → same `PushRouter` |
240
+ | Anchored detail | `.popover` + `presentationCompactAdaptation(.popover)` + `selectedID` binding |
241
+ | Delete-while-presented | `dismiss()` first, then mutate; prune `selectedID` on list change |
@@ -0,0 +1,176 @@
1
+ # TESTING — Strategy & Patterns
2
+
3
+ Swift Testing (`@Test` / `#expect`), not XCTest — the only exception is XCUITest UI automation, which the framework forces onto `XCTestCase`. All domain tests live in the Core package and run with plain `swift test` — no simulator, no Xcode project, < 10 s.
4
+
5
+ ## 1. Test Pyramid
6
+
7
+ | Layer | Coverage | How |
8
+ |---|---|---|
9
+ | **Core package** (engines, services, models) | Exhaustive unit tests | `swift test --package-path Packages/{{PROJECT_NAME}}Core`. Pure logic, fast, deterministic. This is where ~95% of tests live. |
10
+ | **DataLayer** (CloudKit repositories) | Mapping & persistence-format tests, offline | Gate `swift build --package-path Packages/DataLayer` today (the skeleton ships no test target — `swift test` would exit 1, "no tests found"). Add a `DataLayerTests` target and switch to `swift test` once you have `CKRecord`↔domain round-trips, change-token store, merge policy — all in memory, no iCloud account. Real cloud IO (push, share accept) is validated manually on device. `InMemory*Repository` actors in Core keep every Store/Service testable without a database. |
11
+ | **UI** (SwiftUI views) | Thin XCUITest smoke/flow tests + simulator-MCP validation | XCUITests launch the app on the in-memory backend (`-uitest-mock` launch arg) so flows are deterministic and instant. Keep them few — views stay dumb; logic worth testing belongs in Core. |
12
+
13
+ What makes this work: engines are `public enum` with only `static` pure functions, `nonisolated` by nature, with `Calendar` (and `now: Date`) injected. No singletons, no system-clock reads, no I/O. Testable by construction.
14
+
15
+ ```swift
16
+ public enum AchievementEngine {
17
+ public static func satisfiedIDs(
18
+ profile: Profile,
19
+ items: [Item],
20
+ calendar: Calendar = .current // injected — tests pass a fixed UTC calendar
21
+ ) -> Set<String> { ... }
22
+ }
23
+ ```
24
+
25
+ ## 2. Swift Testing Patterns
26
+
27
+ One `@Suite struct` per engine/service; `@Test` functions; `#expect` for assertions; `await #expect(throws: SomeError.x)` for async error paths.
28
+
29
+ ### Fixture helpers (`TestSupport.swift`)
30
+
31
+ A single `Fixture` enum provides builder functions with defaults, so each test only spells out what matters:
32
+
33
+ ```swift
34
+ extension Calendar {
35
+ /// Deterministic calendar for tests (Gregorian, UTC) so day/hour boundaries are stable.
36
+ static var utcTest: Calendar {
37
+ var calendar = Calendar(identifier: .gregorian)
38
+ calendar.timeZone = TimeZone(identifier: "UTC")!
39
+ return calendar
40
+ }
41
+ }
42
+
43
+ enum Fixture {
44
+ static let calendar = Calendar.utcTest
45
+
46
+ static func date(_ year: Int, _ month: Int, _ day: Int,
47
+ _ hour: Int = 12, _ minute: Int = 0) -> Date {
48
+ var c = DateComponents()
49
+ (c.year, c.month, c.day, c.hour, c.minute) = (year, month, day, hour, minute)
50
+ return calendar.date(from: c)!
51
+ }
52
+
53
+ static func item(lat: Double = 48.85, lon: Double = 2.35,
54
+ country: String? = "FR", rating: Int = 3,
55
+ at occurredAt: Date) -> Item {
56
+ Item(coordinate: Coordinate(latitude: lat, longitude: lon),
57
+ countryCode: country, rating: rating,
58
+ occurredAt: occurredAt, createdAt: occurredAt)
59
+ }
60
+ }
61
+ ```
62
+
63
+ Rules:
64
+ - **Explicit dates always.** Every test passes `Fixture.date(2026, 6, 1)` — never `Date()`, never `.now`. Domain logic that reads the system clock is untestable; pass `now: Date` as a parameter instead.
65
+ - **Vary coordinates to isolate rules.** When a test needs N distinct places, use `lat: Double(index)` so place-dedup rules don't interfere with the rule under test.
66
+ - **Test both sides of every boundary.** A rule "hour in 5..<8" gets one test at 6 (unlocks) and one at 8 (must NOT unlock — exclusive bound).
67
+
68
+ ## 3. Catalog-Consistency Tests
69
+
70
+ Whenever content lives in a catalog (achievements, ranks, themes) AND in engine rules, add one test that locks them together:
71
+
72
+ ```swift
73
+ @Test func catalogIsConsistent() {
74
+ let ids = AchievementCatalog.all.map(\.id)
75
+ #expect(Set(ids).count == ids.count) // no duplicate ids
76
+ #expect(ids.allSatisfy { AchievementID(rawValue: $0) != nil }) // no orphan slug / typo
77
+ #expect(AchievementCatalog.all.count == AchievementID.allCases.count) // full coverage
78
+ }
79
+ ```
80
+
81
+ Pair it with a typed-ID enum inside the engine: rules insert `AchievementID` cases (typo = compile error) and convert to `String` slugs only at the return boundary.
82
+
83
+ > ⚠️ **Gotcha:** A new catalog entry shipped with no engine rule — visible in the UI, permanently locked, zero test failures. Cause: catalog and engine were two unlinked lists. Fix: the consistency test above. **Never hardcode counts** (`#expect(ids.count == 37)`); derive from `CaseIterable.allCases.count` so adding content can't silently desync, and the test never needs editing.
84
+
85
+ ## 4. Service Tests via InMemory Repositories
86
+
87
+ Every repository protocol gets a thread-safe `actor InMemory*Repository` in the Core package itself (also reused by SwiftUI previews). Services are then tested end-to-end without persistence:
88
+
89
+ ```swift
90
+ @Suite struct ItemServiceTests {
91
+ let calendar = Fixture.calendar
92
+
93
+ private func makeService(profile: Profile = Profile(id: "u"), items: [Item] = []) -> ItemService {
94
+ ItemService(repository: InMemoryItemRepository(profile: profile, items: items),
95
+ calendar: calendar)
96
+ }
97
+
98
+ @Test func addFirstItemAwardsXPAndFirstBadge() async throws {
99
+ let service = makeService()
100
+ let result = try await service.addItem(
101
+ ItemDraft(coordinate: .init(latitude: 48, longitude: 2),
102
+ occurredAt: Fixture.date(2026, 6, 1)),
103
+ now: Fixture.date(2026, 6, 1)) // `now` injected, never read inside
104
+ #expect(result.xpGained == 25)
105
+ #expect(result.newAchievements.contains { $0.id == "first_item" })
106
+ }
107
+ }
108
+ ```
109
+
110
+ Test the *invariants*, not just happy paths: delete reverses item XP but keeps earned achievements; recompute is idempotent (`second.newAchievements.isEmpty`); best-streak is monotonic across deletions; validation errors throw (`await #expect(throws: GroupValidationError.emptyName) { ... }`).
111
+
112
+ ## 5. Concurrency Tests (actor reentrancy)
113
+
114
+ > ⚠️ **Gotcha:** Concurrent `addItem` calls lost writes despite the service being an `actor`. Cause: actors are **reentrant across `await`** — two read-modify-write sequences interleaved at the repository `await` and the second save clobbered the first. Fix: a `serialized()` operation queue inside the service; proven by tests, not by reading the code:
115
+
116
+ ```swift
117
+ @Test func concurrentAddsLoseNoWrites() async throws {
118
+ let service = makeService()
119
+ await withTaskGroup(of: Void.self) { group in
120
+ for i in 0..<25 {
121
+ group.addTask { _ = try? await service.addItem(draft(index: i), now: day) }
122
+ }
123
+ }
124
+ let snapshot = try await service.snapshot()
125
+ #expect(snapshot.items.count == 25) // no lost updates
126
+ // persisted XP == one deterministic from-scratch recompute over the final item set
127
+ #expect(snapshot.profile.xp == ProgressEngine.recompute(
128
+ identity: Profile(id: "u"), items: snapshot.items, calendar: calendar).profile.xp)
129
+ }
130
+ ```
131
+
132
+ The "converges to a deterministic recompute" assertion catches both double-counting and lost increments — stronger than counting alone.
133
+
134
+ ## 6. Merge / Sync Logic Tests
135
+
136
+ For local↔server merge (`Profile.merged(with:)`), test the field-by-field policy explicitly:
137
+ - Monotonic fields (`xp`, `streakBest`, unlocked-achievement set): max / union — **never regress**, and merge must be **symmetric** (`a.merged(b) == b.merged(a)` on those fields — test it).
138
+ - Non-monotonic fields (`streakCurrent`, `lastActiveDay`): follow the more recent day, **even if the value shrinks** — write a test documenting that shrinking is intentional, or someone will "fix" it.
139
+
140
+ ## 7. Determinism Gotchas
141
+
142
+ > ⚠️ **Gotcha:** Time-window rules (e.g. "22:00–05:00") passed locally, failed on CI. Cause: `Calendar.current` uses the machine's timezone; an hour-boundary test is a different wall-clock hour elsewhere. Fix: inject `Calendar` into every engine function; tests always pass `Calendar.utcTest` (Gregorian + UTC).
143
+
144
+ > ⚠️ **Gotcha:** "Most visited place" flickered between app launches. Cause: tie between two equally-visited places resolved by `Dictionary` iteration order, which is nondeterministic. Fix: stable tie-break (smaller key wins) + a test computing stats on `items` and `items.reversed()` and asserting equal output.
145
+
146
+ > ⚠️ **Gotcha:** Two coordinates centimeters apart counted as two distinct places. Cause: place keys built by stringifying rounded doubles — `-0.0` and `0.0` stringify differently around the equator/meridian. Fix: integer quantization before keying; regression test asserts `placeKey` of `+0.00001` and `-0.00001` are both `"0,0"` and contain no `"-0"`.
147
+
148
+ > ⚠️ **Gotcha:** UI showed a "7-day streak" days after it died. Cause: `currentStreak` is frozen at the last activity day; nothing recomputes it until the next write. Fix: a separate `liveStreak` derived with an explicit `now:` parameter (0 if last activity > 1 day ago); tested at now = same day, next day (still alive — user can still extend), and +2 days (dead).
149
+
150
+ General rules:
151
+ - `sorted()` before comparing collections or picking "first" from grouped data — `Set`/`Dictionary` order is never stable.
152
+ - Engines never call `Date()`, `Calendar.current`, `TimeZone.current`, or `Locale.current` internally. All injected.
153
+ - Weekday/weekend logic must be locale-independent: match `calendar.component(.weekday, ...)` values (1 = Sunday, 7 = Saturday), never rely on `firstWeekday`.
154
+
155
+ ## 8. How AI Agents Must Use Tests
156
+
157
+ 1. **Run `swift test --package-path Packages/{{PROJECT_NAME}}Core` after EVERY engine/domain change.** It's seconds. No "I'll run it at the end".
158
+ 2. **Every new rule ships with its tests** in the same change: the unlock case, the just-below-threshold case, and the boundary case. A rule without a test does not exist.
159
+ 3. **New catalog entry?** The consistency test fails until you add the matching enum case + engine rule. That failure is the workflow — don't weaken the test.
160
+ 4. **Tests are the spec.** When behavior is ambiguous, the test file is the authority (e.g. "merge can shrink current streak" is documented intent, not a bug). Read the relevant suite before changing an engine.
161
+ 5. **Never edit an existing assertion to make your change pass** without understanding why it was written — most encode a fixed production bug. If a behavior change is intentional, update the test *and* its doc comment.
162
+
163
+ ## UI / snapshot tests — when to add them (V2, deliberately not V1)
164
+ MVP skips UI tests on purpose: layouts churn too fast and brittle tests slow every slice. Add them
165
+ when (a) a screen has survived 3+ slices unchanged, or (b) a visual regression actually bit you.
166
+ Then prefer snapshot tests of MODULE bricks (L4 — stable contracts, no navigation) over full-screen
167
+ flows, and keep them in the app target, not the packages. Until then: the simulator screenshot at
168
+ every slice gate IS the UI regression net — actually look at it.
169
+
170
+ ## Concurrency testing (Swift)
171
+ - The Thread Sanitizer is the truth serum: `swift test --sanitize=thread` on packages periodically
172
+ (it's slow — not every run; before each release at minimum).
173
+ - A concurrency bug fixed = a regression test that reproduces the race (e.g. N concurrent calls via
174
+ `TaskGroup` asserting a single side effect) — same discipline as any other gotcha.
175
+ - Swift 6 strict concurrency catches data races at compile time; never silence it with
176
+ `@unchecked Sendable` to make a test pass — fix the isolation instead.
@@ -0,0 +1,165 @@
1
+ # WORKFLOW — Dev Loop & AI-Agent Operating Manual
2
+
3
+ How to ship features in {{PROJECT_NAME}}. Prescriptive. Follow exactly.
4
+
5
+ ## 1. Slice-Based Delivery
6
+
7
+ Ship **vertical slices**: thin end-to-end features (UI → domain → persistence), never horizontal layers across the whole app. One slice = one shippable increment validated on simulator.
8
+
9
+ **Per slice:**
10
+ 1. **Blueprint first** — write the slice's blueprint as a section of `docs/SLICES.md` before any
11
+ code (single canonical file — per-slice standalone files drift). Must contain:
12
+ - The key architectural decision for the slice (e.g. "one CKRecordZone per Group, root record carries the CKShare") and why alternatives were rejected.
13
+ - Phases mapped to layers, each with an explicit file list (`NEW`/`MODIFY`) and signatures.
14
+ - A "Gotchas" section (known traps for the APIs involved).
15
+ - A production checklist (schema deploys, entitlements, device-only validations).
16
+ 2. **Implement layer by layer**, gating each phase:
17
+ - **Phase A — Core package** (pure Swift, zero IO): domain types, engines, repository *protocols*, `InMemory*Repository`, service actors. Gate: `swift test` green.
18
+ - **Phase B — DataLayer package** (CloudKit/IO, depends on Core): record mapping, repositories as actors. Gate: `swift build` green today (DataLayer ships no test target yet); `swift test` once you add one for the mapping round-trips (they run offline, in memory).
19
+ - **Phase C — Module + App**: reusable UI components in `Module/`, then screens (`View / @Observable ViewModel / struct Interactor`). Gate: `xcodebuild build` green + app launched + screens rendered from sample data.
20
+ 3. **Validate on simulator** (screenshots, tapping through flows — see §3).
21
+ 4. **Update memory files** (§5) before considering the slice done.
22
+
23
+ Track slice status with checkboxes in `NEXT_STEPS.md`; log what was actually built (with test counts and verification status) in `PROJECT_STATE.md`.
24
+
25
+ ## 2. Build & Test Loop (agents: this exact order)
26
+
27
+ **Packages FIRST. Always.** `swift test` on a pure package takes seconds and gives precise errors; `xcodebuild` takes minutes and buries them.
28
+
29
+ ```bash
30
+ # 1. Per-package, fast, no simulator needed
31
+ swift test --package-path Packages/{{PROJECT_NAME}}Core
32
+ swift build --package-path Packages/DataLayer # swift test once DataLayer has a test target (CKRecord mapping round-trips)
33
+ swift build --package-path Packages/{{PROJECT_NAME}}DS
34
+
35
+ # 2. App target — ONLY when all packages are green (xcodegen puts the .xcodeproj at the repo root)
36
+ xcodebuild -project {{PROJECT_NAME}}.xcodeproj -scheme {{PROJECT_NAME}} \
37
+ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
38
+ -derivedDataPath /tmp/{{PROJECT_NAME}}_dd build 2>&1 | grep -E "error:|warning:.*deprecated|BUILD"
39
+
40
+ # 3. App-target tests (only for app-layer logic: stores, formatting, UI tests)
41
+ xcodebuild -project {{PROJECT_NAME}}.xcodeproj -scheme {{PROJECT_NAME}} \
42
+ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test 2>&1 | grep -E "error:|Test Suite|passed|failed"
43
+ ```
44
+
45
+ - Pin `-derivedDataPath /tmp/{{PROJECT_NAME}}_dd` so the built `.app` is at a known path for install.
46
+ - Always pipe through `grep -E "error:|BUILD"` — never dump full xcodebuild output into context.
47
+ - Quote paths: a `&` or space in a directory name breaks zsh silently.
48
+ - New code that is testable without UI goes in a package, not the app target — that's the whole point of the split.
49
+
50
+ > ⚠️ **Gotcha — XcodeGen owns the project, not the pbxproj:** this project is generated by XcodeGen from `project.yml`, and the `.xcodeproj` is gitignored. Source files are picked up from the `sources:` globs in `project.yml` and packages from its `packages:`/`dependencies:` — adding a new `.swift` file under the app folder is included automatically on the next `xcodegen generate` (NOT by Xcode synchronized folders — those aren't in play here). Adding a *new local package* means editing `project.yml` (a `packages:` entry + a `dependencies:` line on the target), then re-running `xcodegen generate`. Never hand-edit the `.xcodeproj` — it's regenerated and your edit is lost.
51
+
52
+ ### Simulator install / launch / visual check
53
+
54
+ After a green build, install and drive the app via the iOS-simulator MCP tools:
55
+
56
+ 1. Rebuild with `-derivedDataPath /tmp/{{PROJECT_NAME}}_dd`.
57
+ 2. `install_app` with `/tmp/{{PROJECT_NAME}}_dd/Build/Products/Debug-iphonesimulator/{{PROJECT_NAME}}.app`.
58
+ 3. `launch_app` with terminate-running enabled.
59
+ 4. `screenshot` to verify rendering; `ui_describe_all` for element coords; `ui_tap` / `ui_type` / `ui_swipe` to drive flows end-to-end.
60
+
61
+ `screenshot` / `launch_app` / `record_video` ride on `simctl` and always work. `ui_tap` / `ui_describe_all` / `ui_find_element` require **idb**: `brew install facebook/fb/idb-companion` + `uv tool install fb-idb`, verify with `idb list-targets`.
62
+
63
+ > ⚠️ **Gotcha — stale simulator binary:** the app on the simulator can be an old build (you tap a button that "doesn't exist"). **Always reinstall the current build before visual verification.** Symptom: feature missing on screen that compiles fine. Cause: you launched yesterday's binary. Fix: rebuild → `install_app` → `launch_app`, every time.
64
+
65
+ > ⚠️ **Gotcha — SwiftUI toolbar items invisible to accessibility tooling:** navigation-bar items (back, "+") are collapsed into an unexposed "Nav bar" group, so `ui_find_element` never finds them. Tap visual coordinates instead (vertical center of the nav bar ≈ y=88 on current iPhones).
66
+
67
+ > ⚠️ **Gotcha — CloudKit account probe stalls UI tests:** `CKContainer.accountStatus` can hang ~150 s on a freshly cloned simulator, freezing app startup. Fix: a launch argument (e.g. `-uitest-mock`) that makes the store bootstrap force the in-memory backend instantly; pass it in every UITest and agent-driven launch.
68
+
69
+ ## 3. Device Debugging: `os.Logger`, never `print()`
70
+
71
+ `print()` output is only visible when Xcode's debugger is attached. On a real device launched from the home screen — exactly when CloudKit, push, and share-acceptance bugs appear — `print()` goes nowhere. Use `os.Logger`:
72
+
73
+ ```swift
74
+ import os
75
+
76
+ let logger = Logger(subsystem: "{{BUNDLE_ID}}", category: "Store")
77
+
78
+ logger.info("account=\(status.rawValue, privacy: .public) backend=\(backendName, privacy: .public)")
79
+ logger.error("bootstrap failed: \(error.localizedDescription, privacy: .public)")
80
+ ```
81
+
82
+ - Interpolated values are `<private>` by default — add `privacy: .public` to every value you actually need to read, or the log is useless.
83
+ - Read logs in Xcode (Window → Devices, or the console while running) or Console.app filtered by subsystem. Logs persist even when the app was launched without a debugger.
84
+ - Log at every backend decision point: account status, chosen backend (cloud vs in-memory), and raw server errors.
85
+
86
+ > ⚠️ **Gotcha — bug invisible on simulator, found only via device logs:** real-device sync "silently" loaded 0 items while the simulator (in-memory fallback) looked fine. Device logs showed CloudKit error 12/2006: *"cannot use an empty list to initialize a new field"*. Cause: CloudKit (Development) infers a List field's type at first save and **rejects an empty array** — so any fresh user whose record had `unlockedIDs = []` failed its very first save. Fix: in `CKRecord` mapping, **omit list fields when empty** (reads default to `[]`; the schema field is created on the first non-empty save). Without device logging this is undiagnosable.
87
+
88
+ > ⚠️ **Gotcha — CKShare acceptance never fires in AppDelegate:** with the SwiftUI App lifecycle, `userDidAcceptCloudKitShareWith` is delivered to the **scene delegate**, not the app delegate. Provide a `SceneDelegate` via `application(_:configurationForConnecting:options:)` (`delegateClass`) and implement `windowScene(_:userDidAcceptCloudKitShareWith:)`. Log in both handlers to see which fires. Also: invite links show "you need a newer version of this app" unless Info.plist contains `CKSharingSupported = true`.
89
+
90
+ ## 4. Memory System (anti-hallucination)
91
+
92
+ Persistent context lives in `.claude/memory/`. **Restore at session start** (read all five files before doing anything). **Update after every significant change** — a session whose work isn't in memory didn't happen, because the next session can't see it.
93
+
94
+ | File | Contains |
95
+ |------|----------|
96
+ | `PROJECT_STATE.md` | What the app is, stack, package list with test counts, per-slice done log, **session-by-session changelog with verification status**, gotchas discovered, known remaining issues. The priority file. |
97
+ | `ARCHITECTURE.md` | Technical structure: layers, patterns (VVM-I, actors), package dependency graph, data flow. |
98
+ | `DECISIONS.md` | Numbered table (`D1, D2, …`): decision + one-line *why*. Append-only; reference IDs (e.g. "per D11") instead of re-litigating. |
99
+ | `NEXT_STEPS.md` | Roadmap with checkboxes per slice/phase, deferred findings, tech debt with file:line pointers. |
100
+ | `COMMANDS.md` | Real, verified commands (build, test, simulator, scripts) + environment quirks. Copy-paste ready. |
101
+
102
+ Rules:
103
+ - **Never invent project facts.** If memory doesn't cover it, read the code or ask.
104
+ - Record *negative* results too ("clustering: SwiftUI `Map` still has no native API even on iOS 26 — verified against docs; hand-rolled instead"). They prevent the next session from re-exploring dead ends.
105
+ - Track unfixable-offline issues explicitly (e.g. a "needs 2 iCloud accounts / real device" list) so they aren't silently forgotten before release.
106
+
107
+ ## 5. Validation Etiquette (non-negotiable)
108
+
109
+ 1. **Never claim "done" without building.** "It should work" is not a status. Minimum bar: packages `swift test` green + app `xcodebuild build` green.
110
+ 2. **UI changes require a screenshot.** Rebuild → reinstall → launch → screenshot (and tap through the flow for interactions). "Build succeeded" says nothing about rendering.
111
+ 3. **Logic changes require tests.** New domain rules get unit tests in the owning package, including non-regression tests for every fixed bug (the empty-list fix above shipped with one).
112
+ 4. **Report status honestly, in tiers:**
113
+ - *Tested* — unit tests cover it, green.
114
+ - *Verified on simulator* — seen working via screenshot/taps.
115
+ - *Builds, unverified* — compiles; behavior not observed.
116
+ - *Device-only, NOT validated* — push, share acceptance, real iCloud accounts. Say so explicitly and keep a running list; never imply these work because the simulator fallback ran.
117
+ 5. **Run adversarial review before release-grade milestones.** Confirmed findings get fixed *and re-validated* (tests + build + simulator); deferred findings go to `NEXT_STEPS.md` with the reason.
118
+
119
+ > ⚠️ **Gotcha — orphaned popover after deleting its anchor:** deleting an item from its own anchored popover left the popover floating (anchor gone), blocked selecting other items, and made the deleted marker "re-pop" while panning (SwiftUI recreated the annotation to serve the anchor). Fix: the detail card dismisses itself via `@Environment(\.dismiss)` on delete, AND the map clears `selectedID` in `onChange(of: items.ids)` when the selected item disappears. Verify deletion flows by actually deleting on the simulator — this class of bug is invisible to builds and unit tests.
120
+
121
+ > ⚠️ **Gotcha — UITests welded to display copy:** UITests matching localized button text break on every rewording. Put stable `accessibilityIdentifier`s on test-critical controls (FABs, tabs, primary CTAs, markers) and match those; keep `accessibilityLabel` for VoiceOver.
122
+
123
+
124
+ ## Tooling gate (run BEFORE Phase 1 of any kickoff — hard stop, not a parting note)
125
+ ```bash
126
+ xcodegen --version # required to generate the .xcodeproj
127
+ xcrun simctl list runtimes | grep iOS # a runtime must match project.yml's deploymentTarget
128
+ ```
129
+ Missing tool → tell the user the one-line install NOW (`brew install xcodegen`) and agree on the
130
+ degraded-proof plan below before writing any code. Never discover this mid-build.
131
+
132
+ ## Degraded-proof ladder (no .xcodeproj / no simulator available)
133
+ L5 app-target sources cannot be left "written but never compiled" — that shipped a broken screen
134
+ once. When `xcodegen`/simulator are unavailable, the MINIMUM proof is:
135
+ ```bash
136
+ # 1. Build each package for the simulator (works WITHOUT an .xcodeproj — xcodebuild understands SPM):
137
+ cd Packages/{{PROJECT_NAME}}DS && xcodebuild -scheme {{PROJECT_NAME}}DS \
138
+ -destination 'generic/platform=iOS Simulator' -derivedDataPath /tmp/dd build
139
+ # (repeat for Core and DataLayer)
140
+ # 2. Typecheck the app-target sources against those products:
141
+ xcrun swiftc -typecheck -parse-as-library -swift-version 6 \
142
+ -default-isolation MainActor -module-name {{PROJECT_NAME}} \
143
+ -sdk $(xcrun --sdk iphonesimulator --show-sdk-path) \
144
+ -target arm64-apple-ios26.0-simulator \
145
+ -I /tmp/dd/Build/Products/Debug-iphonesimulator $(find {{PROJECT_NAME}} -name '*.swift')
146
+ # -parse-as-library (required for @main) · -default-isolation MainActor (mirrors the app
147
+ # target's SWIFT_DEFAULT_ACTOR_ISOLATION) · without these the typecheck fails on valid sources
148
+ ```
149
+ Report which rung was reached (simulator screenshot > app build > typecheck > package tests).
150
+ A lower rung is acceptable ONLY if stated explicitly in the slice report and logged as debt.
151
+
152
+ > ⚠️ **Gotcha:** Symptom — a demo value ("~12 min", "5 items") presented as "computed by the
153
+ > engine" turns out fabricated. Rule — any number/string attributed to code must come from
154
+ > actually executed output (test log, REPL, app run). If you didn't run it, label it an estimate.
155
+
156
+
157
+ ## Launch checklist (before ANY external testers)
158
+ - CloudKit: schema deployed Dev→Prod, `aps-environment` = production (see CLOUDKIT_GUIDE).
159
+ - User-generated content (shared groups, names, notes): report + block + ≤24h takedown path
160
+ (App Store Guideline 1.2 — rejection otherwise).
161
+ - Age rating honest to the content; age gate in-app if 17+/18+.
162
+ - Privacy: policy URL + App Privacy labels; precise location = sensitive (GDPR Art. 9 when
163
+ combined with intimate-life data) — collect the minimum, state the purpose.
164
+ - Shared-data privacy review: what does a group member SEE about others (stable user ids?
165
+ exact coordinates?) — decide precision/pseudonymization consciously, log it in DECISIONS.md.
@@ -0,0 +1,48 @@
1
+ # CI for {{PROJECT_NAME}} — proof over claims, automated from the first push.
2
+ name: CI
3
+ on:
4
+ push: { branches: [main, develop] }
5
+ pull_request:
6
+ jobs:
7
+ packages:
8
+ runs-on: macos-latest
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - name: Select newest Xcode (templates use swift-tools 6.2 → Xcode 26+)
12
+ # macos-latest defaults to an older Xcode whose Swift can't build swift-tools 6.2.
13
+ # Pick the newest installed Xcode (mirrors the AppForge scaffold-swift job).
14
+ run: |
15
+ set -o pipefail
16
+ LATEST="$(ls -d /Applications/Xcode*.app | sort -V | tail -n 1)"
17
+ echo "Using $LATEST"
18
+ sudo xcode-select -s "$LATEST"
19
+ swift --version
20
+ - name: Core tests (the spec)
21
+ run: swift test --package-path Packages/{{PROJECT_NAME}}Core
22
+ - name: DS builds
23
+ run: swift build --package-path Packages/{{PROJECT_NAME}}DS
24
+ - name: DataLayer builds
25
+ run: swift build --package-path Packages/DataLayer
26
+ - name: L5 typecheck ladder (no .xcodeproj needed)
27
+ # Build EVERY package for the simulator into ONE shared derivedData, then typecheck the
28
+ # app sources against ALL of their products — app code imports DS + Core + DataLayer, so a
29
+ # single-package -I would fail 'no such module'. A shared -derivedDataPath collects every
30
+ # .swiftmodule under one Products dir. pipefail makes `... | tail -1` surface a failed build
31
+ # instead of masking it behind tail's exit 0.
32
+ run: |
33
+ set -o pipefail
34
+ ROOT="$PWD"
35
+ DD=/tmp/dd
36
+ for pkg in {{PROJECT_NAME}}DS {{PROJECT_NAME}}Core DataLayer; do
37
+ ( cd "$ROOT/Packages/$pkg" && \
38
+ xcodebuild -scheme "$pkg" \
39
+ -destination 'generic/platform=iOS Simulator' \
40
+ -derivedDataPath "$DD" build 2>&1 | tail -1 )
41
+ done
42
+ PRODUCTS="$DD/Build/Products/Debug-iphonesimulator"
43
+ xcrun swiftc -typecheck -parse-as-library -swift-version 6 \
44
+ -default-isolation MainActor -module-name {{PROJECT_NAME}} \
45
+ -sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \
46
+ -target arm64-apple-ios26.0-simulator \
47
+ -I "$PRODUCTS" \
48
+ $(find {{PROJECT_NAME}} -name '*.swift')
@@ -0,0 +1,5 @@
1
+ xcuserdata/
2
+ *.xcodeproj
3
+ DerivedData/
4
+ .build/
5
+ .swiftpm/
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "ios-simulator": {
4
+ "command": "npx",
5
+ "args": ["-y", "ios-simulator-mcp"]
6
+ }
7
+ }
8
+ }