@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.
- package/LICENSE +32 -0
- package/README.md +99 -0
- package/bin/cli.js +371 -0
- package/bin/cli.test.js +91 -0
- package/package.json +43 -0
- package/templates/core/CLAUDE.md +36 -0
- package/templates/core/claude/memory/ARCHITECTURE.md +20 -0
- package/templates/core/claude/memory/COMMANDS.md +13 -0
- package/templates/core/claude/memory/DECISIONS.md +5 -0
- package/templates/core/claude/memory/NEXT_STEPS.md +11 -0
- package/templates/core/claude/memory/PROJECT_STATE.md +24 -0
- package/templates/core/claude/skills/kickoff/SKILL.md +84 -0
- package/templates/core/claude/skills/product-owner/SKILL.md +58 -0
- package/templates/core/claude/skills/restore-context/SKILL.md +29 -0
- package/templates/core/claude/skills/save-context/SKILL.md +35 -0
- package/templates/core/docs-architecture/ANTI_PATTERNS.md +180 -0
- package/templates/core/docs-architecture/ARCHITECTURE_PRINCIPLES.md +134 -0
- package/templates/core/docs-architecture/DELIVERY.md +68 -0
- package/templates/core/docs-architecture/DOCS_PLACEMENT.md +151 -0
- package/templates/core/docs-architecture/MULTI_REPO_CONTRACT.md +158 -0
- package/templates/core/docs-architecture/SDK_CONTRACT.md +214 -0
- package/templates/core/docs-architecture/SECURITY_USER_URLS.md +152 -0
- package/templates/core/gitignore +15 -0
- package/templates/core/mcp.json +8 -0
- package/templates/packs/nuxt-web/CLAUDE.md +74 -0
- package/templates/packs/nuxt-web/app/app.vue +5 -0
- package/templates/packs/nuxt-web/app/assets/css/main.css +18 -0
- package/templates/packs/nuxt-web/app/assets/css/tokens.css +41 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/components/DSButton.vue +70 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/index.ts +4 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/tests/DSButton.spec.ts +34 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/types/dsButton.ts +5 -0
- package/templates/packs/nuxt-web/app/domain/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/features/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/pages/index.vue +36 -0
- package/templates/packs/nuxt-web/app/utils/.gitkeep +0 -0
- package/templates/packs/nuxt-web/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/nuxt-web/docs-architecture/ARCHITECTURE.md +169 -0
- package/templates/packs/nuxt-web/docs-architecture/CONVENTIONS.md +140 -0
- package/templates/packs/nuxt-web/docs-architecture/I18N.md +102 -0
- package/templates/packs/nuxt-web/docs-architecture/OPS_WEB.md +176 -0
- package/templates/packs/nuxt-web/docs-architecture/SEO_AND_ROUTING.md +118 -0
- package/templates/packs/nuxt-web/gitignore +18 -0
- package/templates/packs/nuxt-web/nuxt.config.ts +49 -0
- package/templates/packs/nuxt-web/pack.json +11 -0
- package/templates/packs/nuxt-web/package.json +31 -0
- package/templates/packs/nuxt-web/playwright.config.ts +39 -0
- package/templates/packs/nuxt-web/server/api/health.get.ts +7 -0
- package/templates/packs/nuxt-web/tests/e2e/home.spec.ts +19 -0
- package/templates/packs/nuxt-web/tsconfig.json +4 -0
- package/templates/packs/nuxt-web/vitest.config.ts +23 -0
- package/templates/packs/swift-ios/CLAUDE.md +64 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Package.swift +21 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Sources/DataLayer/DataLayer.swift +11 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Package.swift +20 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Domain/SampleItem.swift +15 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Engine/SampleEngine.swift +14 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Repository/SampleItemRepository.swift +27 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Tests/{{PROJECT_NAME}}CoreTests/SampleEngineTests.swift +32 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Package.swift +17 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Color+DS.swift +18 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Components/DSCard.swift +22 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DS.swift +36 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DSFont.swift +26 -0
- package/templates/packs/swift-ios/claude/memory/COMMANDS.md +18 -0
- package/templates/packs/swift-ios/docs-architecture/ARCHITECTURE.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/CLOUDKIT_GUIDE.md +224 -0
- package/templates/packs/swift-ios/docs-architecture/CONVENTIONS.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/DESIGN_SYSTEM.md +272 -0
- package/templates/packs/swift-ios/docs-architecture/NAVIGATION.md +241 -0
- package/templates/packs/swift-ios/docs-architecture/TESTING.md +176 -0
- package/templates/packs/swift-ios/docs-architecture/WORKFLOW.md +165 -0
- package/templates/packs/swift-ios/github/workflows/ci.yml +48 -0
- package/templates/packs/swift-ios/gitignore +5 -0
- package/templates/packs/swift-ios/mcp.json +8 -0
- package/templates/packs/swift-ios/pack.json +11 -0
- package/templates/packs/swift-ios/project.yml +33 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/App.swift +32 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/AppNamespace.swift +4 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Module/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Store/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Tools/.gitkeep +0 -0
- package/templates/packs/ts-sdk/CHANGELOG.md +9 -0
- package/templates/packs/ts-sdk/CLAUDE.md +72 -0
- package/templates/packs/ts-sdk/MIGRATION.md +28 -0
- package/templates/packs/ts-sdk/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/ts-sdk/docs-architecture/ARCHITECTURE.md +132 -0
- package/templates/packs/ts-sdk/docs-architecture/CONVENTIONS_TS.md +152 -0
- package/templates/packs/ts-sdk/gitignore +6 -0
- package/templates/packs/ts-sdk/pack.json +11 -0
- package/templates/packs/ts-sdk/package.json +55 -0
- package/templates/packs/ts-sdk/scripts/verify-dist.mjs +67 -0
- package/templates/packs/ts-sdk/src/clients/AuthClient.ts +168 -0
- package/templates/packs/ts-sdk/src/core/HttpClient.ts +85 -0
- package/templates/packs/ts-sdk/src/core/Logger.ts +27 -0
- package/templates/packs/ts-sdk/src/core/SDKContext.ts +40 -0
- package/templates/packs/ts-sdk/src/core/withTimeout.ts +19 -0
- package/templates/packs/ts-sdk/src/errors/ApiError.ts +93 -0
- package/templates/packs/ts-sdk/src/index.ts +62 -0
- package/templates/packs/ts-sdk/src/types/index.ts +33 -0
- package/templates/packs/ts-sdk/tests/apiError.test.ts +58 -0
- package/templates/packs/ts-sdk/tests/httpClient.test.ts +60 -0
- package/templates/packs/ts-sdk/tests/singleFlight.test.ts +191 -0
- package/templates/packs/ts-sdk/tsconfig.json +15 -0
- package/templates/packs/ts-sdk/tsup.config.ts +22 -0
- package/templates/packs/ts-sdk/vitest.config.ts +8 -0
- package/templates/packs/vapor-api/CLAUDE.md +73 -0
- package/templates/packs/vapor-api/Dockerfile +80 -0
- package/templates/packs/vapor-api/Package.swift +68 -0
- package/templates/packs/vapor-api/Sources/App/App.swift +5 -0
- package/templates/packs/vapor-api/Sources/App/Configure/AppConfig.swift +108 -0
- package/templates/packs/vapor-api/Sources/App/Configure/configure.swift +74 -0
- package/templates/packs/vapor-api/Sources/App/Configure/entrypoint.swift +47 -0
- package/templates/packs/vapor-api/Sources/App/Configure/routes.swift +21 -0
- package/templates/packs/vapor-api/Sources/App/Error/Failed.swift +73 -0
- package/templates/packs/vapor-api/Sources/App/Error/FailedMiddleware.swift +56 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/AppItem.swift +38 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Controllers/ItemControllersCrud.swift +41 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/DTO/ItemDTO.swift +22 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Entities/ItemEntity.swift +30 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Migrations/ItemMigrationCreate.swift +25 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Repositories/ItemRepository.swift +32 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Services/ItemService.swift +57 -0
- package/templates/packs/vapor-api/Sources/App/Registry/ControllersRegister.swift +17 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MiddlewaresRegister.swift +15 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MigrationsRegister.swift +18 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Logging/JSONLogHandler.swift +59 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Middleware/HTTPLoggingMiddleware.swift +50 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Monitoring.swift +110 -0
- package/templates/packs/vapor-api/Sources/{{PROJECT_NAME}}Foundation/String+Trimmed.swift +15 -0
- package/templates/packs/vapor-api/Tests/AppTests/AppTests.swift +155 -0
- package/templates/packs/vapor-api/claude/memory/COMMANDS.md +30 -0
- package/templates/packs/vapor-api/docs-architecture/ARCHITECTURE.md +144 -0
- package/templates/packs/vapor-api/docs-architecture/CONVENTIONS.md +121 -0
- package/templates/packs/vapor-api/docs-architecture/GOTCHAS_LINUX_SWIFT.md +109 -0
- package/templates/packs/vapor-api/docs-architecture/OPS.md +102 -0
- package/templates/packs/vapor-api/env_dist +29 -0
- package/templates/packs/vapor-api/gitignore +7 -0
- package/templates/packs/vapor-api/pack.json +11 -0
- package/templates/packs/vapor-api/scripts/generate-error-codes.sh +73 -0
- 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.
|