@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,246 @@
1
+ # CONVENTIONS — SwiftUI Architecture & Swift 6.2 Concurrency
2
+
3
+ Conventions for {{PROJECT_NAME}} (Swift 6.2, iOS 26, strict concurrency). Prescriptive — follow as written.
4
+
5
+ ## 1. Architecture: VVM-I (View + ViewModel + Interactor + Translate)
6
+
7
+ Every **screen** lives in `App/<Screen>/`, split into up to 4 files:
8
+
9
+ | File | Type | Role |
10
+ |---|---|---|
11
+ | `App<Screen>.swift` | `struct ... : View` | Layout only. Reads state, delegates to Interactor. |
12
+ | `App<Screen>ViewModel.swift` | `@Observable final class` | UI state only (nav path, selection, sheet flags). No domain logic, no IO. |
13
+ | `App<Screen>Interactor.swift` | `struct` | Actions + derived data. The ONLY layer that talks to Stores/services. |
14
+ | `App<Screen>+Translate.swift` | `nonisolated enum` | User-facing strings + pure formatting. No state. |
15
+
16
+ ```swift
17
+ // App/Feed/AppFeed.swift
18
+ extension App {
19
+ struct Feed: View {
20
+ @Environment(ItemStore.self) private var store
21
+ @State private var viewModel = ViewModel()
22
+ private var interactor: Interactor { Interactor(store: store, viewModel: viewModel) }
23
+ }
24
+ }
25
+
26
+ extension App.Feed {
27
+ var body: some View {
28
+ NavigationStack(path: $viewModel.path) {
29
+ // sections read interactor.<derived>, call interactor.<action>()
30
+ }
31
+ .navigationTitle(Translate.title)
32
+ .navigationDestination(for: ItemGroup.self) { App.GroupDetail(group: $0) }
33
+ }
34
+ }
35
+
36
+ // App/Feed/AppFeedViewModel.swift
37
+ extension App.Feed {
38
+ @Observable final class ViewModel {
39
+ var path: [ItemGroup] = [] // this tab's nav stack — pushed by the Interactor
40
+ var selectedBadgeID: String? // drives an anchored popover
41
+ }
42
+ }
43
+
44
+ // App/Feed/AppFeedInteractor.swift
45
+ extension App.Feed {
46
+ struct Interactor {
47
+ let store: ItemStore
48
+ let viewModel: ViewModel
49
+ var invitations: [ItemGroup] { store.pendingInvitations } // derived data
50
+ func openGroup(_ group: ItemGroup) { viewModel.path.append(group) } // navigation
51
+ func accept(_ group: ItemGroup) async { await store.acceptInvitation(group) } // side effect
52
+ }
53
+ }
54
+
55
+ // App/Feed/AppFeed+Translate.swift
56
+ extension App.Feed {
57
+ nonisolated enum Translate {
58
+ static let title = "Activity"
59
+ static func greeting(_ name: String) -> String { "Hi \(name)" }
60
+ }
61
+ }
62
+ ```
63
+
64
+ Rules:
65
+ - Interactor is a **stateless struct**, recreated each render via a computed property. State lives in ViewModel (UI) or Store (domain) — never in the Interactor.
66
+ - Navigation goes through the Interactor (`viewModel.path.append(...)`), never inline in `body`.
67
+ - Each tab owns its `NavigationStack` + value-based `.navigationDestination(for:)`. No global path-based coordinator.
68
+ - **When each part is optional:** pure-display bricks = View only. Screen with UI state but no store actions = View + ViewModel + Translate (skip Interactor). Translate exists as soon as a file has user-facing copy — never inline strings in `body`.
69
+
70
+ Folder layout:
71
+ - `App/` — screens (one folder per screen, VVM-I files).
72
+ - `Module/` — reusable UI bricks (`Module/Map/`, `Module/Badge/`…): plain Views, closure-driven, store-free.
73
+ - `Store/` — `@MainActor @Observable` domain facades.
74
+ - `Packages/` — SPM: `Core` (pure domain, zero IO → `swift test` runs without a simulator), `DataLayer` (persistence/cloud), `{{PROJECT_NAME}}DS` (design system).
75
+
76
+ ## 2. Swift 6.2 strict concurrency
77
+
78
+ - App target sets `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` — everything is MainActor unless marked otherwise. UI packages opt in with `.defaultIsolation(MainActor.self)` in `Package.swift`.
79
+ - The pure domain package keeps **nonisolated** default + `.swiftLanguageMode(.v6)`. Engines are `enum`s of static pure functions:
80
+
81
+ ```swift
82
+ // Core package — pure, nonisolated, deterministic, instantly testable
83
+ public enum BadgeEngine {
84
+ public static func satisfiedIDs(profile: Profile, items: [Item],
85
+ calendar: Calendar = .current) -> Set<String> {
86
+ var ids = Set<BadgeID>() // typed ID enum: a typo is a compile error
87
+ if items.count >= 1 { ids.insert(.first) }
88
+ // … all rules are pure functions of (profile, items, calendar) — injected Calendar
89
+ return Set(ids.map(\.rawValue)) // convert to stored String slugs at the boundary
90
+ }
91
+ }
92
+ ```
93
+
94
+ - `async/await` everywhere. **No completion handlers.** Framework delegate callbacks (e.g. `CLLocationManagerDelegate`) are `nonisolated`; hop back to the main actor explicitly to publish state.
95
+ - **Never `@unchecked Sendable`.** `nonisolated(unsafe)` is tolerated only in tests, with a comment explaining exactly which sending check it silences and why it is safe.
96
+ - View-lifetime async work: `.task { await store.bootstrap() }` and `.task(id:)` — auto-cancelled on disappear. Fire-and-forget from a tap: `Button { Task { await interactor.accept(group) } }`. Parallel fan-out: `withTaskGroup` / `async let`. No detached tasks.
97
+ - Mark `Translate` enums `nonisolated` explicitly: under MainActor default isolation they would otherwise be actor-isolated, blocking use from nonisolated formatting/background code.
98
+
99
+ ## 3. The Store: `@MainActor @Observable` domain facade
100
+
101
+ ```swift
102
+ @MainActor @Observable
103
+ final class ItemStore {
104
+ private(set) var items: [Item] = [] // ALL read state is private(set)
105
+ private(set) var isReady = false
106
+ var lastError: String? // user-facing; one alert binds to it
107
+ private var service: ItemService?
108
+
109
+ func bootstrap() async {
110
+ guard service == nil else { return } // idempotent — .task can re-fire
111
+ // pick cloud backend if account available, else in-memory seeded with sample data
112
+ }
113
+
114
+ @discardableResult
115
+ func addItem(_ draft: ItemDraft) async -> Bool {
116
+ guard let service else { lastError = "Service unavailable."; return false }
117
+ lastError = nil // start the action clean
118
+ do { _ = try await service.add(draft); await refresh(); return true }
119
+ catch {
120
+ log.error("addItem failed: \(error, privacy: .public)")
121
+ lastError = "Couldn't save. Try again."
122
+ return false
123
+ }
124
+ }
125
+ }
126
+
127
+ extension ItemStore {
128
+ static func preview() -> ItemStore { /* seeded with SampleData for #Preview */ }
129
+ }
130
+ ```
131
+
132
+ Rules: mutations only through async funcs; every catch logs the operation name + maps to a human `lastError`; write actions return `Bool` so sheets dismiss **only when the write landed**; always provide a `static preview()` factory; in-memory backend doubles as offline/demo mode and is forced by a launch argument in UI tests.
133
+
134
+ > ⚠️ **Gotcha:** alert shows a stale error from a *previous* action. Cause: `lastError` survives across actions. Fix: reset `lastError = nil` as the first line of every store action.
135
+
136
+ > ⚠️ **Gotcha:** spinner never ends when the first load fails. Cause: `isReady` only set on success. Fix: set `isReady = true` in the catch too — surface an empty state + error, never spin forever.
137
+
138
+ > ⚠️ **Gotcha:** UI tests hang on a cold simulator. Cause: the cloud account-status probe stalls the first load. Fix: check `ProcessInfo.processInfo.arguments.contains("-uitest-mock")` in `bootstrap()` and wire the in-memory backend — deterministic and instant.
139
+
140
+ > ⚠️ **Gotcha:** first cloud sync fires a local notification for every pre-existing remote item. Cause: "new since last refresh" has no baseline. Fix: on first load, just seed the persisted "seen IDs" set without notifying; only diff against it on subsequent refreshes.
141
+
142
+ ## 4. Namespacing by extension scoping
143
+
144
+ Scope feature views inside the domain type they render; scope screens inside `App`:
145
+
146
+ ```swift
147
+ extension Item { struct DetailCard: View { … } } // call site: Item.DetailCard(item:)
148
+ extension ItemGroup { struct InviteCard: View { … } } // call site: ItemGroup.InviteCard(group:…)
149
+ extension App { struct Feed: View { … } } // call site: App.Feed()
150
+ ```
151
+
152
+ > ⚠️ **Gotcha:** the `App` namespace collides with the `SwiftUI.App` protocol. Two rules make it
153
+ > safe: (1) declare the namespace once — `enum App {}` in `App/AppNamespace.swift` (the scaffold
154
+ > ships it); (2) the @main entry point must be declared `struct {{PROJECT_NAME}}App: SwiftUI.App`
155
+ > (fully qualified) or it won't compile.
156
+
157
+ > ⚠️ **Gotcha:** a multi-statement `var body: some View` inside an `extension App.X` silently
158
+ > loses the implicit `@ViewBuilder` in some configurations — annotate `@ViewBuilder var body`
159
+ > explicitly in extension-scoped views, or keep `body` a single expression.
160
+
161
+ - File name = call-site path without the dot: `ItemDetailCard.swift`, `AppFeed.swift`, `AppFeed+Translate.swift`. **One type per file.**
162
+ - Why: free namespacing without submodules, no name clashes (`Item.DetailCard` vs `SharedItem.DetailCard` coexist), call sites read as domain language.
163
+ - Shared/non-domain bricks (skeletons, rating controls, generic map) stay top-level structs in `Module/`.
164
+
165
+ ## 5. Closures as module boundaries
166
+
167
+ Module bricks never import Stores. Parents inject actions as closures; the brick stays reusable and testable in isolation:
168
+
169
+ ```swift
170
+ extension ItemGroup {
171
+ struct InviteCard: View {
172
+ let group: ItemGroup
173
+ // Side effect to know: a View storing closures can't be Equatable, so SwiftUI can't
174
+ // skip its body via the Equatable fast-path. The parent recreates these closures every
175
+ // render → this card re-renders with the parent. That's FINE: closures run on tap (not
176
+ // render), @State persists by position, no retain cycle (struct), no stale capture
177
+ // (value capture of `group`, reference to the store). Keep the design — it decouples
178
+ // the brick from the store. If a large list ever hurts: conform Equatable on group.id.
179
+ var onAccept: () -> Void
180
+ var onDecline: () -> Void
181
+ }
182
+ }
183
+ ```
184
+
185
+ - Use optional closures (`var onDelete: (() -> Void)? = nil`) when absence changes the UI (e.g. hide the delete button).
186
+ - **Document the re-render side effect once** on a reference brick and point other bricks to it.
187
+
188
+ Generic bricks take the same idea further — one component, callers inject types and views:
189
+
190
+ ```swift
191
+ struct ItemMap<Item: Identifiable, Marker: View, Detail: View>: View where Item.ID == UUID {
192
+ @Binding var position: MapCameraPosition
193
+ let items: [Item]
194
+ let coordinate: (Item) -> Coordinate
195
+ var onCenterChange: ((Coordinate) -> Void)? = nil // reported continuously on camera move
196
+ @ViewBuilder let marker: (Item) -> Marker
197
+ @ViewBuilder let detail: (Item) -> Detail // shown in a popover ANCHORED to the marker
198
+ @State private var selectedID: UUID?
199
+ }
200
+ ```
201
+
202
+ One map brick serves the personal screen AND every group screen; each injects its own typed marker/detail. Detail UI is an anchored `.popover` + `.presentationCompactAdaptation(.popover)` — never a centered sheet. Don't re-stamp glass effects; iOS 26 applies them natively on nav/toolbar/sheet/popover.
203
+
204
+ > ⚠️ **Gotcha:** after deleting an item, SwiftUI keeps re-creating the dead annotation on every camera change. Cause: the selection still points at the removed item, so the orphaned popover resurrects it. Fix: clear the selection when the item disappears:
205
+ > ```swift
206
+ > .onChange(of: items.map(\.id)) { _, ids in
207
+ > if let s = selectedID, !ids.contains(s) { selectedID = nil }
208
+ > }
209
+ > ```
210
+
211
+ > ⚠️ **Gotcha:** delete-from-popover glitches (popover orphans mid-removal). Cause: the destructive closure removes the item while its anchored popover is still presented. Fix: `dismiss()` **first**, then call `onDelete?()`.
212
+
213
+ > ⚠️ **Gotcha:** map stutters while panning. Cause: continuous camera callbacks recompute derived state (clustering) every frame. Fix: only refresh when zoom changes meaningfully (e.g. >5% span delta), not on every pan frame.
214
+
215
+ ## 6. Logging (OSLog)
216
+
217
+ ```swift
218
+ import OSLog
219
+ let log = Logger(subsystem: "{{BUNDLE_ID}}", category: "Store")
220
+ log.notice("bootstrap: backend=\(label, privacy: .public) items=\(items.count)")
221
+ ```
222
+
223
+ - One `Logger` per layer (`category: "Store"`, `"Sync"`, …). Every `catch` logs the failed operation by name.
224
+ - Stream from a device: `log stream --device --predicate 'subsystem == "{{BUNDLE_ID}}"'` — put this command in a doc comment next to the Logger.
225
+
226
+ > ⚠️ **Gotcha:** device logs show `<private>` instead of values. Cause: OSLog redacts interpolations by default. Fix: annotate non-sensitive debug values with `privacy: .public`. Never apply it to user content or tokens.
227
+
228
+ ## 7. Naming quick reference
229
+
230
+ - Actions: verbs (`addItem`, `acceptInvitation`); derived data: nouns (`invitations`, `unlockedCount`).
231
+ - Booleans read as assertions: `isReady`, `needsDisplayName`, `isArchived`.
232
+ - Extension files: `TypeName+Role.swift` (`AppFeed+Translate.swift`).
233
+ - Centralize magic values: design tokens in `DS.Padding/Radius/Size`, the app display name and container IDs in single constants.
234
+ - Comments explain **why** (the decision, the side effect, the trap), never what the code already says.
235
+
236
+
237
+ ## One type per file — the sanctioned exception
238
+ A repository **contract and its InMemory reference implementation may share one file**
239
+ (`Repository/ItemRepository.swift`): they form one teaching unit and ship/replace together.
240
+ Everything else stays one type per file. (Generated code violated the strict rule in every
241
+ audited project — codifying the exception beats pretending.)
242
+
243
+ > ⚠️ **Gotcha (previews):** Symptom — a preview renders empty while the app works. Cause — the
244
+ > preview built TWO store instances (one injected into the view, a different one bootstrapped
245
+ > with data). Fix — create ONE `Store.preview()` instance, seed it, and inject THAT everywhere
246
+ > in the preview.
@@ -0,0 +1,272 @@
1
+ # Design System Package — {{PROJECT_NAME}}DS
2
+
3
+ All visual tokens (colors, fonts, spacing, radius, gradients, reusable chrome) live in a dedicated local SPM package: `Packages/{{PROJECT_NAME}}DS`. App code **never** hardcodes a color, font size, padding, or corner radius. If a view needs a visual value, it imports `{{PROJECT_NAME}}DS` and uses a token.
4
+
5
+ ## Why a package (not a folder in the app target)
6
+
7
+ - **Enforced via imports.** A file that uses `Color.DS.accent` must `import {{PROJECT_NAME}}DS`. Raw values (`Color(red:...)`, `.padding(17)`, `.font(.system(size: 13))`) are instantly visible in review/lint — they are the *only* place numbers appear outside the package.
8
+ - **Previewable standalone.** The package builds without the app. Components (`DSCard`, and any chrome you add) get previews inside the package; designers iterate without app build times.
9
+ - **AI agents cannot "forget" it.** Agents grep the package for the token vocabulary; any raw value they emit stands out in the diff. Prescribe in CLAUDE.md: *"Visual values come from {{PROJECT_NAME}}DS tokens only."*
10
+ - **Reusable.** The same `DS.*` API shape ports between projects; only token values change.
11
+
12
+ ## Package layout (what the skeleton actually ships)
13
+
14
+ ```
15
+ Packages/{{PROJECT_NAME}}DS/
16
+ ├── Package.swift
17
+ └── Sources/{{PROJECT_NAME}}DS/
18
+ ├── DS.swift # namespace: Padding, Radius, Size, ratio, Gradients
19
+ ├── Color+DS.swift # Color.DS.* palette
20
+ ├── DSFont.swift # enum DS.Font type scale + .designSystem(font:) modifier
21
+ └── Components/
22
+ └── DSCard.swift # starter card container (token-only, domain-blind)
23
+ ```
24
+
25
+ That is the entire starter surface. The four token files plus `DSCard` are the contract every
26
+ example below uses. Anything richer (an app-wide background, a glass card, semantic button styles)
27
+ is something you **add at kickoff** — see "Components to add at kickoff" at the end. Do not reference
28
+ an API the package doesn't ship yet; add the file first, then use it.
29
+
30
+ ```swift
31
+ // Package.swift — matches the shipped skeleton (iOS 26 everywhere; app target and packages bump together)
32
+ // swift-tools-version: 6.2
33
+ import PackageDescription
34
+
35
+ let package = Package(
36
+ name: "{{PROJECT_NAME}}DS",
37
+ platforms: [.iOS(.v26), .macOS(.v15)],
38
+ products: [.library(name: "{{PROJECT_NAME}}DS", targets: ["{{PROJECT_NAME}}DS"])],
39
+ targets: [
40
+ .target(
41
+ name: "{{PROJECT_NAME}}DS",
42
+ swiftSettings: [
43
+ .swiftLanguageMode(.v6),
44
+ .defaultIsolation(MainActor.self),
45
+ ]
46
+ ),
47
+ ]
48
+ )
49
+ ```
50
+
51
+ > ⚠️ **Gotcha:** Swift 6 strict concurrency rejects the token statics. Symptom: `Static property 'brand' is not concurrency-safe because non-'Sendable' type 'LinearGradient'…` on every gradient/color token. Cause: `static let` of non-Sendable SwiftUI types in a nonisolated namespace. Fix: set `.defaultIsolation(MainActor.self)` on the package target (tokens are UI-only anyway). If you later replace the shipped `enum DS.Font` with a value-carrying `struct` that must cross isolation, mark it `Sendable` then.
52
+
53
+ Add to the app as a **local package reference** (`Packages/{{PROJECT_NAME}}DS` relative path) and link the library product to the app target.
54
+
55
+ ## Starter token set
56
+
57
+ ### Namespace + layout tokens — `DS.swift`
58
+
59
+ ```swift
60
+ import SwiftUI
61
+
62
+ public enum DS {
63
+ /// Responsive scaling hook. 1.0 for now; derive from screen class later.
64
+ public static let ratio: CGFloat = 1.0
65
+
66
+ public enum Padding {
67
+ public static let xs: CGFloat = 4 * DS.ratio
68
+ public static let s: CGFloat = 8 * DS.ratio
69
+ public static let m: CGFloat = 16 * DS.ratio
70
+ public static let l: CGFloat = 24 * DS.ratio
71
+ public static let xl: CGFloat = 40 * DS.ratio
72
+ }
73
+
74
+ public enum Radius {
75
+ public static let s: CGFloat = 10 * DS.ratio
76
+ public static let m: CGFloat = 16 * DS.ratio
77
+ public static let l: CGFloat = 24 * DS.ratio
78
+ public static let pill: CGFloat = 999
79
+ }
80
+
81
+ public enum Size {
82
+ public static let hairline: CGFloat = 1
83
+ public static let icon: CGFloat = 24 * DS.ratio
84
+ }
85
+
86
+ /// Signature gradients live in the DS namespace (NOT in the Color extension).
87
+ public enum Gradients {
88
+ public static let brand = LinearGradient(
89
+ colors: [Color.DS.accent, Color.DS.accentSecondary],
90
+ startPoint: .topLeading, endPoint: .bottomTrailing
91
+ )
92
+ }
93
+ }
94
+ ```
95
+
96
+ > ⚠️ **Gotcha:** `DS.ratio` is baked in at first static access (`static let`). Symptom: changing `ratio` at runtime does nothing. Cause: tokens are constants, not computed. Keep it as a build-time/launch-time knob; if you need live scaling, make tokens computed `static var` — accepting the perf cost — don't half-migrate.
97
+
98
+ ### Semantic colors — `Color+DS.swift`
99
+
100
+ ```swift
101
+ import SwiftUI
102
+
103
+ public extension Color {
104
+ enum DS {
105
+ // Brand
106
+ public static let accent = Color(red: 0.35, green: 0.45, blue: 1.00)
107
+ public static let accentSecondary = Color(red: 0.55, green: 0.30, blue: 0.95)
108
+ // Dark-first base: surfaces are white-alpha layered over the background
109
+ public static let background = Color(red: 0.039, green: 0.039, blue: 0.059)
110
+ public static let surface = Color.white.opacity(0.06)
111
+ public static let surfaceStrong = Color.white.opacity(0.10)
112
+ public static let stroke = Color.white.opacity(0.12)
113
+ public static let textPrimary = Color.white
114
+ public static let textSecondary = Color.white.opacity(0.6)
115
+ public static let textTertiary = Color.white.opacity(0.4)
116
+ // Status
117
+ public static let success = Color(red: 0.20, green: 0.85, blue: 0.45)
118
+ public static let danger = Color(red: 1.00, green: 0.27, blue: 0.36)
119
+ }
120
+ }
121
+ ```
122
+
123
+ Gradients (`DS.Gradients.brand`) live in `DS.swift` (shown above), not in this file — the Color
124
+ extension holds only flat colors.
125
+
126
+ > ⚠️ **Gotcha:** `Color.DS` shadows the top-level `DS` namespace inside `extension Color`. Symptom: `DS.ratio` / `DS.Padding` "not found" or resolves to the wrong type in that file. Cause: Swift name lookup prefers the nested `Color.DS`. Fix: qualify with the module name (`{{PROJECT_NAME}}DS.DS.ratio`) inside `Color+DS.swift`, or keep raw values local to that file (it's the one file allowed to contain them).
127
+
128
+ > ⚠️ **Gotcha:** Opacity-based surfaces stack. Symptom: a card nested inside another card is visibly lighter than the spec. Cause: `white.opacity(0.06)` over `white.opacity(0.06)` compounds. Fix: never nest `surface` in `surface`; inner emphasis uses `surfaceStrong` deliberately, or restructure the layout.
129
+
130
+ ### Type scale + modifier — `DSFont.swift`
131
+
132
+ The skeleton ships `DS.Font` as a **case enum** mapping each role to a system font — call sites read
133
+ `.designSystem(font: .h3)`. (A value-carrying `struct` with `size`/`weight`/`design` is a valid V2
134
+ upgrade if you bundle a custom font; until then the enum is the shipped, simpler shape — extend it,
135
+ don't swap it just to match older prose.)
136
+
137
+ ```swift
138
+ import SwiftUI
139
+
140
+ public extension DS {
141
+ /// Typography scale. Use through `.designSystem(font:)` — never `.font(.system(...))` in app code.
142
+ enum Font {
143
+ case largeTitle, title, h2, h3, body, callout, caption, button, mono
144
+
145
+ var font: SwiftUI.Font {
146
+ switch self {
147
+ case .largeTitle: .system(size: 34, weight: .bold)
148
+ case .title: .system(size: 28, weight: .bold)
149
+ case .h2: .system(size: 22, weight: .semibold)
150
+ case .h3: .system(size: 17, weight: .semibold)
151
+ case .body: .system(size: 17)
152
+ case .callout: .system(size: 15)
153
+ case .caption: .system(size: 12)
154
+ case .button: .system(size: 17, weight: .semibold)
155
+ case .mono: .system(size: 15, design: .monospaced)
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ public extension View {
162
+ func designSystem(font: DS.Font) -> some View { self.font(font.font) }
163
+ }
164
+ ```
165
+
166
+ > ⚠️ **Gotcha:** `DS.Font` shadows `SwiftUI.Font` inside the `DS` extension. Symptom: `'Font' has no member 'system'` / circular reference errors. Cause: unqualified `Font` resolves to the enum being defined. Fix: the mapping property's return type is written `SwiftUI.Font` (fully qualified) inside `DSFont.swift`; do the same for any `SwiftUI.Font.Weight` / `.Design` you reference.
167
+
168
+ ### Components (chrome, not features)
169
+
170
+ The `Components/` folder owns visual chrome reused everywhere — kept feature-agnostic (no domain
171
+ types, no networking). **The skeleton ships exactly one: `DSCard`.**
172
+
173
+ ```swift
174
+ // Components/DSCard.swift — the one component that ships
175
+ public struct DSCard<Content: View>: View {
176
+ private let content: Content
177
+ public init(@ViewBuilder content: () -> Content) { self.content = content() }
178
+
179
+ public var body: some View {
180
+ content
181
+ .padding(DS.Padding.m)
182
+ .frame(maxWidth: .infinity, alignment: .leading)
183
+ .background(Color.DS.surface, in: RoundedRectangle(cornerRadius: DS.Radius.m))
184
+ .overlay(
185
+ RoundedRectangle(cornerRadius: DS.Radius.m)
186
+ .stroke(Color.DS.stroke, lineWidth: DS.Size.hairline)
187
+ )
188
+ }
189
+ }
190
+ ```
191
+
192
+ Call site: `DSCard { Text("Hello").designSystem(font: .body) }`.
193
+
194
+ ### Components to add at kickoff (NOT shipped — build them when a slice needs them)
195
+
196
+ These are the usual next bricks. They do **not** exist in the skeleton — add the file, then use the
197
+ API. Don't call any of them before you've written it (the compiler will tell you, but the doc won't
198
+ pretend they're there):
199
+
200
+ - **`DSBackground` + `.dsBackground()`** — `ZStack { Color.DS.background; radial accent glows }.ignoresSafeArea()`,
201
+ exposed as a `.dsBackground()` modifier that wraps content in a `ZStack` (background behind, content
202
+ stays in safe area). Gotcha to anticipate: applying it via plain `.background(DSBackground())` clips
203
+ the glows to the view's bounds — wrap in a `ZStack` and let `DSBackground` call `.ignoresSafeArea()`
204
+ so consumers never manage safe areas for the backdrop.
205
+ - **`GlassCard { … }`** — a heavier card than `DSCard`: `surface` fill, `Radius.l` continuous corners,
206
+ `stroke` hairline border, optional iOS 26 glass effect.
207
+ - **Semantic button styles** — `DSPrimaryButtonStyle` / `DSSecondaryButtonStyle`, exposed via
208
+ `static var dsPrimary` / `dsSecondary` on `ButtonStyle` so call sites read `.buttonStyle(.dsPrimary)`.
209
+
210
+ Add each as its own file under `Components/`, token-only, with a `#Preview`. Once it exists it's a
211
+ first-class part of the vocabulary above.
212
+
213
+ ## Dark-first design
214
+
215
+ The app is dark-first: `background` is near-black, text is white-alpha, surfaces are white-alpha overlays. Tokens are *semantic* (`textSecondary`, `surfaceStrong`, `stroke`) — never named after raw values (`white60`, `gray2`). Call sites express intent; a future light theme means editing one file.
216
+
217
+ > ⚠️ **Gotcha:** Dark palette without dark scheme. Symptom: sheets, alerts, and keyboards render system-light — white sheet, white `textPrimary` text = invisible content. Cause: hardcoded dark tokens don't switch UIKit-managed chrome. Fix: set `.preferredColorScheme(.dark)` at the app root so system chrome matches the palette.
218
+
219
+ ## Usage style (consumer)
220
+
221
+ ```swift
222
+ import SwiftUI
223
+ import {{PROJECT_NAME}}DS
224
+
225
+ struct ItemDetailCard: View {
226
+ let item: Item
227
+
228
+ var body: some View {
229
+ VStack(alignment: .leading, spacing: DS.Padding.m) {
230
+ HStack(spacing: DS.Padding.m) {
231
+ Image(systemName: item.icon)
232
+ .foregroundStyle(AnyShapeStyle(DS.Gradients.brand))
233
+ .background(Color.DS.surfaceStrong, in: Circle())
234
+ Text(item.name)
235
+ .designSystem(font: .h3)
236
+ .foregroundStyle(Color.DS.textPrimary)
237
+ }
238
+ Text(item.detail)
239
+ .designSystem(font: .body)
240
+ .foregroundStyle(Color.DS.textSecondary)
241
+ Divider().overlay(Color.DS.stroke)
242
+ Label(item.status, systemImage: "checkmark.seal.fill")
243
+ .designSystem(font: .callout)
244
+ .foregroundStyle(Color.DS.success)
245
+ }
246
+ .padding(DS.Padding.l)
247
+ .clipShape(RoundedRectangle(cornerRadius: DS.Radius.m, style: .continuous))
248
+ }
249
+ }
250
+ ```
251
+
252
+ Note the pattern: `.designSystem(font:)` sets typography only; color is always a separate `.foregroundStyle(Color.DS.*)`. Mixing a gradient and a color in one conditional requires `AnyShapeStyle(...)` on both branches.
253
+
254
+ ## How to add a token
255
+
256
+ 1. **Check it doesn't exist** — grep `Sources/{{PROJECT_NAME}}DS/` first. Prefer reusing a semantic token over adding a near-duplicate.
257
+ 2. Add the `static let` in the right file (`Color+DS.swift`, `DS.swift`, or `DSFont.swift`) with a **semantic name** (what it's *for*, not what it *looks like*).
258
+ 3. Multiply layout values by `DS.ratio`.
259
+ 4. Use it from the app via the token only. Never copy the raw value to a call site "just this once".
260
+
261
+ ## Rules (for humans and AI agents)
262
+
263
+ - Do use `DS.Padding.*`, `DS.Radius.*`, `Color.DS.*`, `DS.Gradients.*`, `.designSystem(font:)` for every visual value. Never `.padding(12)`, `Color(red:...)`, `.font(.system(...))` in app code — raw values belong only inside the DS package.
264
+ - Do put reusable visual chrome (cards, backgrounds, button styles) in `Components/`. Never put feature logic, models, or networking in the DS package — it stays dependency-free.
265
+ - Corner shapes: always `RoundedRectangle(cornerRadius: DS.Radius.*, style: .continuous)`.
266
+ - Hairlines: `DS.Size.hairline` with `strokeBorder(Color.DS.stroke, ...)`.
267
+
268
+
269
+ > Note — this doc describes ONLY what `Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/`
270
+ > actually ships (the four token files + `Components/DSCard.swift`). Everything under "Components to
271
+ > add at kickoff" is a future addition, not an existing API. If code and doc ever disagree, THE
272
+ > SCAFFOLD FILES ARE THE TRUTH — extend them rather than rewriting them to match prose.