fix: use tool-agnostic ~/.agents/ as default install path

Copilot, Claude, Cursor, and others all read from ~/.agents/.
The npx skills CLI handles fan-out to tool-specific directories.
This commit is contained in:
Matt Bruce 2026-02-11 12:13:20 -06:00
parent 20446c5224
commit 88e4402d38
10 changed files with 2091 additions and 6 deletions

View File

@ -60,10 +60,12 @@ That's it.
| Asset | Default Location | Override | | Asset | Default Location | Override |
|-------|-----------------|----------| |-------|-----------------|----------|
| Registry skills | Managed by `npx skills` CLI | — | | Registry skills | Managed by `npx skills` CLI | — |
| Custom skills | `~/.copilot/skills/` | `SKILLS_DIR` | | Custom skills | `~/.agents/skills/` | `SKILLS_DIR` |
| Agents | `~/.copilot/agents/` | `AGENTS_DIR` | | Agents | `~/.agents/agents/` | `AGENTS_DIR` |
| Instructions | `./instructions/` | `INSTRUCTIONS_DIR` | | Instructions | `./instructions/` | `INSTRUCTIONS_DIR` |
> The `~/.agents/` directory is tool-agnostic. Copilot, Claude, Cursor, and others all read from it. If you need a tool-specific path, override with the env var.
## Adding New Assets ## Adding New Assets
- **Agents or instructions** — Drop the file into `assets/agents/` or `assets/instructions/` and push. Auto-discovered. - **Agents or instructions** — Drop the file into `assets/agents/` or `assets/instructions/` and push. Auto-discovered.

View File

@ -16,9 +16,12 @@ set -euo pipefail
VERSION="2.1.0" VERSION="2.1.0"
# ── Configuration (override with env vars) ─────────────────────────── # ── Configuration (override with env vars) ───────────────────────────
# Default paths use ~/.agents/ — the tool-agnostic directory.
# The npx skills CLI copies into tool-specific dirs (~/.copilot/,
# ~/.claude/, ~/.cursor/) automatically.
ASSETS_BASE_URL="${ASSETS_BASE_URL:-}" ASSETS_BASE_URL="${ASSETS_BASE_URL:-}"
AGENTS_DIR="${AGENTS_DIR:-$HOME/.copilot/agents}" AGENTS_DIR="${AGENTS_DIR:-$HOME/.agents/agents}"
SKILLS_DIR="${SKILLS_DIR:-$HOME/.copilot/skills}" SKILLS_DIR="${SKILLS_DIR:-$HOME/.agents/skills}"
INSTRUCTIONS_DIR="${INSTRUCTIONS_DIR:-./instructions}" INSTRUCTIONS_DIR="${INSTRUCTIONS_DIR:-./instructions}"
REPO_TOKEN="${REPO_TOKEN:-}" REPO_TOKEN="${REPO_TOKEN:-}"
@ -334,8 +337,8 @@ ${BOLD}EXAMPLES${NC}
${BOLD}ENVIRONMENT VARIABLES${NC} ${BOLD}ENVIRONMENT VARIABLES${NC}
ASSETS_BASE_URL Base URL for remote downloads (required without clone) ASSETS_BASE_URL Base URL for remote downloads (required without clone)
AGENTS_DIR Install location for agents (default: ~/.copilot/agents) AGENTS_DIR Install location for agents (default: ~/.agents/agents)
SKILLS_DIR Install location for custom skills (default: ~/.copilot/skills) SKILLS_DIR Install location for custom skills (default: ~/.agents/skills)
INSTRUCTIONS_DIR Install location for instructions (default: ./instructions) INSTRUCTIONS_DIR Install location for instructions (default: ./instructions)
REPO_TOKEN Auth token for private repos (optional) REPO_TOKEN Auth token for private repos (optional)

View File

@ -0,0 +1,143 @@
---
name: Swift Clean Architecture
description: File organization, layer separation, folder structures, and proactive refactoring
globs: ["**/*.swift"]
---
# Clean Architecture for Swift
**Separation of concerns is mandatory.** Code should be organized into distinct layers with clear responsibilities and dependencies flowing inward.
## File Organization Rules
### One Public Type Per File
Each file should contain exactly one public struct, class, or enum. Private supporting types may be included only if they are small and used exclusively by the main type.
### Keep Files Lean (300 Line Limit)
Aim for files under 300 lines. If a file exceeds this:
- Extract reusable sub-views into `Components/` folder
- Extract sheets/modals into `Sheets/` folder
- Extract complex logic into dedicated types
- Split private view structs into their own files
### No Duplicate Code
Before writing new code:
1. Search for existing implementations
2. Extract common patterns into reusable components
3. Consider protocol extraction for shared behavior
## Layer Responsibilities
| Layer | Contains | Depends On |
|-------|----------|------------|
| **Views** | SwiftUI views, UI components | State, Models |
| **State** | `@Observable` stores, view models | Models, Services |
| **Services** | Business logic, networking, persistence | Models |
| **Models** | Data types, entities, DTOs | Nothing |
| **Protocols** | Interfaces for services and stores | Models |
### Layer Rules
1. **Views are dumb renderers** - No business logic. Read state and call methods.
2. **State holds business logic** - Computations, validations, data transformations.
3. **Services are stateless** - Pure functions where possible. Injected via protocols.
4. **Models are simple** - Plain data types. No dependencies on UI or services.
## Folder Structures
Choose based on project size and team structure:
### Feature-First (Large Apps / Teams)
Best for: Multiple developers, features that could become SPM packages, complex apps.
```
App/
├── Shared/
│ ├── Design/ # Colors, typography, constants
│ ├── Protocols/ # Shared protocol definitions
│ ├── Services/ # Shared services (networking, auth)
│ └── Components/ # Shared UI components
└── Features/
├── Home/
│ ├── Views/
│ │ ├── HomeView.swift
│ │ ├── Components/
│ │ │ ├── HomeHeaderView.swift
│ │ │ └── HomeCardView.swift
│ │ └── Sheets/
│ │ └── HomeFilterSheet.swift
│ ├── Models/
│ │ └── HomeItem.swift
│ └── State/
│ └── HomeStore.swift
└── Profile/
├── Views/
├── Models/
└── State/
```
### Layer-First (Small/Medium Apps / Solo)
Best for: Solo developers, simpler apps, faster navigation.
```
App/
├── Design/ # Colors, typography, constants
├── Models/ # All data models
├── Protocols/ # All protocol definitions
├── Services/ # All services
├── State/ # All observable stores
└── Views/
├── Components/ # Shared reusable components
├── Sheets/ # Shared modal presentations
├── Home/ # Home feature views
└── Profile/ # Profile feature views
```
## Proactive Refactoring
**The agent will actively identify and suggest fixes for these violations:**
### File Size Violations
When a file exceeds 300 lines, suggest specific extractions:
- "This file is 450 lines. Consider extracting `SomePrivateView` (lines 200-280) to `Components/SomePrivateView.swift`"
### Duplicate Code Detection
When similar code patterns appear:
- "This filtering logic also exists in `OtherStore.swift`. Consider extracting to a shared protocol or utility."
### View Struct Proliferation
When a view file contains multiple private struct definitions:
- "This view has 5 private structs. Extract `HeaderView`, `RowView`, and `FooterView` to the `Components/` folder."
### Misplaced Business Logic
When business logic appears in views:
- "This validation logic belongs in the Store, not the View. Move `isValid` computed property to `FeatureStore`."
### Protocol Extraction Opportunities
When similar interfaces appear across types:
- "Both `UserService` and `TeamService` have similar fetch/save patterns. Consider a `Persistable` protocol."
## Naming Conventions
- **Views**: `FeatureNameView.swift`, `FeatureNameRowView.swift`
- **Stores**: `FeatureNameStore.swift`
- **Models**: `FeatureName.swift` or `FeatureNameModel.swift`
- **Services**: `FeatureNameService.swift`
- **Protocols**: `FeatureNameProviding.swift` or `Persistable.swift`

View File

@ -0,0 +1,228 @@
---
name: Swift Localization
description: Localization patterns using String Catalogs and modern APIs
globs: ["**/*.swift", "**/*.xcstrings"]
---
# Localization with String Catalogs
Use **String Catalogs** (`.xcstrings` files) for localization in modern Swift projects.
## Required Language Support
At minimum, support these languages:
- **English (en)** - Base language
- **Spanish - Mexico (es-MX)**
- **French - Canada (fr-CA)**
## How String Catalogs Work
### Automatic Extraction
SwiftUI `Text` views with string literals are automatically extracted:
```swift
// Automatically added to String Catalog
Text("Hello, World!")
Text("Welcome back, \(user.name)!")
```
### Manual Extraction for Non-Text Strings
For strings outside of `Text` views, use `String(localized:)`:
```swift
// Use String(localized:) for alerts, buttons, accessibility
let title = String(localized: "Delete Item")
let message = String(localized: "Are you sure you want to delete this item?")
// With comments for translators
let greeting = String(
localized: "greeting_message",
defaultValue: "Hello!",
comment: "Greeting shown on the home screen"
)
```
## Never Use NSLocalizedString
```swift
// BAD - Old API
let text = NSLocalizedString("Hello", comment: "Greeting")
// GOOD - Modern API
let text = String(localized: "Hello")
```
## String Interpolation
String Catalogs handle interpolation automatically:
```swift
// In Swift
Text("You have \(count) items")
// In String Catalog, translators see:
// "You have %lld items"
// They can reorder: "Items: %lld" for languages that need different order
```
## Pluralization
Use automatic grammar agreement for plurals:
```swift
// Automatic pluralization
Text("^[\(count) item](inflect: true)")
// Result:
// count = 1: "1 item"
// count = 5: "5 items"
```
For complex pluralization rules, define in String Catalog with plural variants.
## Formatting Numbers, Dates, Currency
Always use formatters - they respect locale automatically:
```swift
// Numbers
Text(price, format: .currency(code: "USD"))
Text(percentage, format: .percent)
Text(count, format: .number)
// Dates
Text(date, format: .dateTime.month().day().year())
Text(date, format: .relative(presentation: .named))
// Measurements
let distance = Measurement(value: 5, unit: UnitLength.miles)
Text(distance, format: .measurement(width: .abbreviated))
```
## Localized String Keys
Use meaningful keys for complex strings:
```swift
// For simple UI text, use the text itself
Text("Settings")
Text("Cancel")
// For complex or contextual strings, use keys
Text("home.welcome.title") // Key in String Catalog
Text("profile.empty.message")
```
## Accessibility Labels
Localize all accessibility content:
```swift
Image(systemName: "heart.fill")
.accessibilityLabel(String(localized: "Favorite"))
Button { } label: {
Image(systemName: "trash")
}
.accessibilityLabel(String(localized: "Delete"))
.accessibilityHint(String(localized: "Removes this item permanently"))
```
## String Catalog Organization
### File Structure
```
App/
├── Localizable.xcstrings # Main strings
├── InfoPlist.xcstrings # Info.plist strings (app name, permissions)
└── Intents.xcstrings # Siri/Shortcuts strings (if applicable)
```
### Comments for Translators
Add comments to help translators understand context:
```swift
Text("Save", comment: "Button to save the current document")
Text("Save", comment: "Menu item to save all changes")
// These become separate entries with context
```
## Common Patterns
### Error Messages
```swift
enum AppError: LocalizedError {
case networkUnavailable
case invalidData
var errorDescription: String? {
switch self {
case .networkUnavailable:
return String(localized: "error.network.unavailable")
case .invalidData:
return String(localized: "error.data.invalid")
}
}
}
```
### Attributed Strings
```swift
var attributedGreeting: AttributedString {
var string = AttributedString(localized: "Welcome to **MyApp**!")
// Markdown formatting is preserved
return string
}
```
### Dynamic Strings from Server
For server-provided strings that need localization:
```swift
// Use a mapping approach
let serverKey = response.messageKey // e.g., "subscription_expired"
let localizedMessage = String(localized: String.LocalizationValue(serverKey))
```
## Testing Localization
### Preview with Different Locales
```swift
#Preview {
ContentView()
.environment(\.locale, Locale(identifier: "es-MX"))
}
#Preview {
ContentView()
.environment(\.locale, Locale(identifier: "fr-CA"))
}
```
### Pseudo-Localization
Enable in scheme to find truncation and layout issues:
1. Edit Scheme → Run → Options
2. Set "Application Language" to a pseudo-language
3. Look for strings that don't expand properly
## Export/Import for Translation
```bash
# Export for translators
xcodebuild -exportLocalizations -project MyApp.xcodeproj -localizationPath ./Localizations
# Import translations
xcodebuild -importLocalizations -project MyApp.xcodeproj -localizationPath ./Localizations/es-MX.xcloc
```

View File

@ -0,0 +1,258 @@
---
name: Swift Model Design
description: Model design patterns including single source of truth and computed properties
globs: ["**/*.swift"]
---
# Model Design Patterns
**Computed properties should be the single source of truth for derived data.**
## Single Source of Truth Principle
Never store data that can be computed from other stored data. This prevents sync bugs and simplifies maintenance.
### Name Fields Pattern
When a model has multiple name components, use a computed property for the display name:
```swift
@Model
final class Person {
var prefix: String = "" // "Dr.", "Mr.", etc.
var firstName: String = ""
var middleName: String = ""
var lastName: String = ""
var suffix: String = "" // "Jr.", "III", etc.
var nickname: String = ""
// GOOD - Computed from individual fields
var fullName: String {
var parts: [String] = []
if !prefix.isEmpty { parts.append(prefix) }
if !firstName.isEmpty { parts.append(firstName) }
if !middleName.isEmpty { parts.append(middleName) }
if !lastName.isEmpty { parts.append(lastName) }
if !suffix.isEmpty { parts.append(suffix) }
return parts.joined(separator: " ")
}
// For display with nickname
var displayName: String {
if !nickname.isEmpty {
return nickname
}
if !firstName.isEmpty {
return firstName
}
return fullName
}
// Plain format for export (no special formatting)
var vCardName: String {
[firstName, middleName, lastName]
.filter { !$0.isEmpty }
.joined(separator: " ")
}
// BAD - Stored displayName that can get out of sync
// var storedDisplayName: String // Never add this
}
```
### Benefits of Computed Properties
- **Always up to date**: Changes to individual fields are immediately reflected
- **No sync bugs**: No risk of stored value diverging from component fields
- **Simpler code**: No need to update derived values when editing source fields
- **Less storage**: No duplicate data in database
## Derived State Patterns
### Counts and Aggregates
```swift
@Model
final class Project {
var name: String = ""
@Relationship(deleteRule: .cascade)
var tasks: [Task]? = []
// GOOD - Computed counts
var taskCount: Int {
tasks?.count ?? 0
}
var completedTaskCount: Int {
tasks?.filter(\.isCompleted).count ?? 0
}
var progress: Double {
guard taskCount > 0 else { return 0 }
return Double(completedTaskCount) / Double(taskCount)
}
var isComplete: Bool {
taskCount > 0 && completedTaskCount == taskCount
}
// BAD - Stored counts that need manual updates
// var storedTaskCount: Int = 0
// var storedCompletedCount: Int = 0
}
```
### Status and State
```swift
@Model
final class Order {
var createdAt: Date = Date()
var paidAt: Date?
var shippedAt: Date?
var deliveredAt: Date?
var cancelledAt: Date?
// GOOD - Computed status
var status: OrderStatus {
if cancelledAt != nil { return .cancelled }
if deliveredAt != nil { return .delivered }
if shippedAt != nil { return .shipped }
if paidAt != nil { return .paid }
return .pending
}
var isActive: Bool {
cancelledAt == nil && deliveredAt == nil
}
var canCancel: Bool {
shippedAt == nil && cancelledAt == nil
}
}
enum OrderStatus: String, Codable {
case pending, paid, shipped, delivered, cancelled
}
```
### Validation State
```swift
@Model
final class UserProfile {
var email: String = ""
var phone: String = ""
var firstName: String = ""
var lastName: String = ""
// GOOD - Computed validation
var isEmailValid: Bool {
email.contains("@") && email.contains(".")
}
var isPhoneValid: Bool {
let digits = phone.filter(\.isNumber)
return digits.count >= 10
}
var isComplete: Bool {
!firstName.isEmpty && !lastName.isEmpty && isEmailValid
}
var validationErrors: [String] {
var errors: [String] = []
if firstName.isEmpty { errors.append("First name is required") }
if lastName.isEmpty { errors.append("Last name is required") }
if !isEmailValid { errors.append("Valid email is required") }
return errors
}
}
```
## When to Store vs Compute
### Store When:
- Data comes from an external source (API, user input)
- Computation is expensive and value is accessed frequently
- Historical accuracy matters (price at time of purchase)
- You need to query/filter by the value in database
### Compute When:
- Value is derived from other stored properties
- Value can change when source properties change
- Keeping values in sync would be error-prone
- Computation is fast (string concatenation, simple math)
## Caching Expensive Computations
For expensive computed values accessed frequently:
```swift
@Observable
@MainActor
final class AnalyticsStore {
private var items: [Item] = []
// Cache invalidation tracking
private var itemsVersion = 0
private var cachedStatsVersion = -1
private var cachedStats: Statistics?
var statistics: Statistics {
// Return cached if valid
if cachedStatsVersion == itemsVersion, let cached = cachedStats {
return cached
}
// Compute and cache
let stats = computeStatistics()
cachedStats = stats
cachedStatsVersion = itemsVersion
return stats
}
func updateItems(_ newItems: [Item]) {
items = newItems
itemsVersion += 1 // Invalidate cache
}
private func computeStatistics() -> Statistics {
// Expensive computation
}
}
```
## Identifiable and Hashable
Implement `Identifiable` for use with SwiftUI lists:
```swift
@Model
final class Item {
var id: UUID = UUID() // Or use @Attribute(.unique) if not using CloudKit
var name: String = ""
}
// SwiftData models are automatically Identifiable if they have an 'id' property
```
For value types used in Sets or as Dictionary keys:
```swift
struct Tag: Hashable, Codable {
let id: UUID
let name: String
// Hashable based on id only
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: Tag, rhs: Tag) -> Bool {
lhs.id == rhs.id
}
}
```

View File

@ -0,0 +1,285 @@
---
name: Modern Swift
description: Modern Swift language patterns, concurrency, and API usage
globs: ["**/*.swift"]
---
# Modern Swift Patterns
Use modern Swift language features and avoid deprecated patterns.
## Concurrency
### Always Mark Observable Classes with @MainActor
```swift
@Observable
@MainActor
final class FeatureStore {
// All properties and methods run on main actor
}
```
### Use Modern Concurrency (No GCD)
```swift
// BAD - Old GCD patterns
DispatchQueue.main.async {
self.updateUI()
}
DispatchQueue.global().async {
let result = self.heavyWork()
DispatchQueue.main.async {
self.handle(result)
}
}
// GOOD - Modern concurrency
await MainActor.run {
updateUI()
}
Task.detached {
let result = await heavyWork()
await MainActor.run {
handle(result)
}
}
```
### Use Task.sleep(for:) Not nanoseconds
```swift
// BAD
try await Task.sleep(nanoseconds: 1_000_000_000)
// GOOD
try await Task.sleep(for: .seconds(1))
try await Task.sleep(for: .milliseconds(500))
```
### Strict Concurrency Compliance
```swift
// Ensure data crossing actor boundaries is Sendable
struct Item: Sendable {
let id: UUID
let name: String
}
// Use @unchecked Sendable only when you've manually verified thread safety
final class Cache: @unchecked Sendable {
private let lock = NSLock()
private var storage: [String: Any] = [:]
}
```
## Foundation APIs
### Use Modern URL APIs
```swift
// BAD - Deprecated patterns
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let file = docs.appendingPathComponent("data.json")
// GOOD - Modern APIs
let docs = URL.documentsDirectory
let file = docs.appending(path: "data.json")
```
### Use Modern Date Formatting
```swift
// BAD - DateFormatter
let formatter = DateFormatter()
formatter.dateStyle = .medium
let string = formatter.string(from: date)
// GOOD - format method
let string = date.formatted(.dateTime.month().day().year())
let relative = date.formatted(.relative(presentation: .named))
```
### Use Modern Number Formatting
```swift
// BAD - C-style or NumberFormatter
let string = String(format: "%.2f", price)
// GOOD - format method
let string = price.formatted(.currency(code: "USD"))
let percent = ratio.formatted(.percent.precision(.fractionLength(1)))
```
## String Handling
### Use localizedStandardContains for User Search
```swift
// BAD - Case-sensitive or manual lowercasing
items.filter { $0.name.lowercased().contains(query.lowercased()) }
// GOOD - Locale-aware, case/diacritic insensitive
items.filter { $0.name.localizedStandardContains(query) }
```
### String Interpolation Over Concatenation
```swift
// BAD
let message = "Hello, " + user.name + "!"
// GOOD
let message = "Hello, \(user.name)!"
```
## Type Safety
### Avoid Force Unwraps
```swift
// BAD
let value = dictionary["key"]!
let url = URL(string: urlString)!
// GOOD - Guard or optional binding
guard let value = dictionary["key"] else {
throw ValidationError.missingKey
}
guard let url = URL(string: urlString) else {
throw NetworkError.invalidURL
}
```
### Avoid Force Casts
```swift
// BAD
let view = cell as! CustomCell
// GOOD
guard let view = cell as? CustomCell else {
assertionFailure("Expected CustomCell")
return
}
```
### Avoid Force Try
```swift
// BAD
let data = try! encoder.encode(object)
// GOOD - Handle the error
do {
let data = try encoder.encode(object)
} catch {
logger.error("Encoding failed: \(error)")
}
```
## Prefer Swift-Native Patterns
### Static Member Lookup
```swift
// BAD - Struct instances
.clipShape(RoundedRectangle(cornerRadius: 8))
// GOOD - Static member lookup
.clipShape(.rect(cornerRadius: 8))
```
### Result Builders Over Imperative Construction
```swift
// BAD - Imperative array building
var views: [AnyView] = []
if showHeader {
views.append(AnyView(HeaderView()))
}
views.append(AnyView(ContentView()))
// GOOD - ViewBuilder
@ViewBuilder
var content: some View {
if showHeader {
HeaderView()
}
ContentView()
}
```
### KeyPath Expressions
```swift
// BAD
items.map { $0.name }
items.sorted { $0.date < $1.date }
// GOOD
items.map(\.name)
items.sorted(using: KeyPathComparator(\.date))
```
## Collections
### Use First(where:) Over Filter().first
```swift
// BAD - Creates intermediate array
let item = items.filter { $0.id == targetId }.first
// GOOD - Short-circuits
let item = items.first { $0.id == targetId }
```
### Use Contains(where:) Over Filter().isEmpty
```swift
// BAD
let hasActive = !items.filter { $0.isActive }.isEmpty
// GOOD
let hasActive = items.contains { $0.isActive }
```
### Use Lazy for Chained Operations
```swift
// Process large collections efficiently
let result = largeArray
.lazy
.filter { $0.isValid }
.map { $0.transformed }
.prefix(10)
.map(Array.init) // Materialize only when needed
```
## Error Handling
### Prefer Typed Throws (Swift 6)
```swift
// Swift 6 - Typed throws
enum NetworkError: Error {
case notFound
case unauthorized
case serverError(Int)
}
func fetch() throws(NetworkError) -> Data {
// ...
}
```
### Use Result for Async Callbacks (When Not Using async/await)
```swift
func fetch(completion: @escaping (Result<Data, NetworkError>) -> Void) {
// ...
}
```

View File

@ -0,0 +1,186 @@
---
name: Swift Protocol-Oriented Programming
description: Protocol-first architecture patterns for reusability and testability
globs: ["**/*.swift"]
---
# Protocol-Oriented Programming (POP)
**Protocol-first architecture is a priority.** When designing new features, always think about protocols and composition before concrete implementations.
## When Architecting New Code
1. **Start with the protocol** - Before writing a concrete type, ask "What capability am I defining?" and express it as a protocol.
2. **Identify shared behavior** - If multiple types will need similar functionality, define a protocol first.
3. **Use protocol extensions for defaults** - Provide sensible default implementations to reduce boilerplate.
4. **Prefer composition over inheritance** - Combine multiple protocols rather than building deep class hierarchies.
## When Reviewing Existing Code
1. **Look for duplicated patterns** - Similar logic across files is a candidate for protocol extraction.
2. **Identify common interfaces** - Types that expose similar properties/methods should conform to a shared protocol.
3. **Check before implementing** - Search for existing protocols that could be adopted or extended.
4. **Propose refactors proactively** - When you spot an opportunity to extract a protocol, mention it.
## Protocol Design Guidelines
### Naming Conventions
Use capability-based suffixes:
- `-able`: `Persistable`, `Shareable`, `Validatable`
- `-ing`: `DataProviding`, `ErrorHandling`, `Loading`
- `-Provider`: `ContentProvider`, `DataProvider`
- `-Delegate`: `NavigationDelegate`, `FormDelegate`
### Keep Protocols Focused
Each protocol should represent one capability (Interface Segregation Principle):
```swift
// GOOD - Focused protocols
protocol Identifiable {
var id: UUID { get }
}
protocol Nameable {
var displayName: String { get }
}
protocol Timestamped {
var createdAt: Date { get }
var updatedAt: Date { get }
}
// Compose as needed
struct User: Identifiable, Nameable, Timestamped { ... }
```
```swift
// BAD - Kitchen sink protocol
protocol Entity {
var id: UUID { get }
var displayName: String { get }
var createdAt: Date { get }
var updatedAt: Date { get }
func save() async throws
func delete() async throws
func validate() -> Bool
}
```
### Associated Types
Use sparingly. Prefer concrete types or generics at the call site when possible:
```swift
// Prefer this for simple cases
protocol DataFetching {
func fetch<T: Decodable>(from url: URL) async throws -> T
}
// Use associated types when the type is fundamental to the protocol
protocol Repository {
associatedtype Entity
func fetch(id: UUID) async throws -> Entity?
func save(_ entity: Entity) async throws
}
```
### Value vs Reference Semantics
Constrain to `AnyObject` only when reference semantics are required:
```swift
// Default - allows structs and classes
protocol Configurable {
mutating func configure(with options: Options)
}
// When you need reference semantics (delegates, observers)
protocol NavigationDelegate: AnyObject {
func didNavigate(to destination: Destination)
}
```
## Protocol Extensions
Provide default implementations for common behavior:
```swift
protocol Validatable {
var validationErrors: [String] { get }
var isValid: Bool { get }
}
extension Validatable {
var isValid: Bool {
validationErrors.isEmpty
}
}
```
## Dependency Injection with Protocols
Define protocols for services to enable testing:
```swift
protocol NetworkServiceProtocol {
func fetch<T: Decodable>(from url: URL) async throws -> T
}
// Production implementation
final class NetworkService: NetworkServiceProtocol { ... }
// Test mock
final class MockNetworkService: NetworkServiceProtocol { ... }
```
## Benefits
- **Reusability** - Shared protocols work across features
- **Testability** - Mock types can conform to protocols for unit testing
- **Flexibility** - New features can adopt existing protocols immediately
- **Maintainability** - Fix a bug in a protocol extension, fix it everywhere
- **Discoverability** - Protocols document the expected interface clearly
## Common Patterns
### Repository Pattern
```swift
protocol Repository {
associatedtype Entity: Identifiable
func fetch(id: Entity.ID) async throws -> Entity?
func fetchAll() async throws -> [Entity]
func save(_ entity: Entity) async throws
func delete(_ entity: Entity) async throws
}
```
### Service Pattern
```swift
protocol AuthServiceProtocol {
var isAuthenticated: Bool { get }
func signIn(email: String, password: String) async throws
func signOut() async throws
}
```
### Coordinator/Navigation Pattern
```swift
protocol NavigationCoordinating: AnyObject {
func navigate(to destination: Destination)
func dismiss()
func presentSheet(_ sheet: SheetType)
}
```

View File

@ -0,0 +1,359 @@
---
name: SwiftUI Accessibility
description: Dynamic Type support and VoiceOver accessibility implementation
globs: ["**/*.swift"]
---
# Accessibility: Dynamic Type and VoiceOver
Accessibility is not optional. All apps must support Dynamic Type and VoiceOver.
## Dynamic Type
### Always Support Dynamic Type
Use system text styles that scale automatically:
```swift
// GOOD - Scales with Dynamic Type
Text("Title")
.font(.title)
Text("Body text")
.font(.body)
Text("Caption")
.font(.caption)
```
### Use @ScaledMetric for Custom Dimensions
When you need custom sizes that should scale with Dynamic Type:
```swift
struct CustomCard: View {
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 24
@ScaledMetric(relativeTo: .body) private var spacing: CGFloat = 12
@ScaledMetric(relativeTo: .title) private var headerHeight: CGFloat = 44
var body: some View {
VStack(spacing: spacing) {
Image(systemName: "star")
.font(.system(size: iconSize))
Text("Content")
}
}
}
```
### Choose Appropriate relativeTo Styles
Match the scaling behavior to the content's purpose:
| Content Type | relativeTo |
|-------------|-----------|
| Body content spacing | `.body` |
| Title decorations | `.title` |
| Caption elements | `.caption` |
| Large headers | `.largeTitle` |
### Fixed Sizes (Use Sparingly)
Only use fixed sizes when absolutely necessary, and document the reason:
```swift
// Fixed size for app icon badge - must match system badge size
private let badgeSize: CGFloat = 24 // Fixed: matches system notification badge
// Fixed for external API requirements
private let avatarUploadSize: CGFloat = 256 // Fixed: server requires exactly 256x256
```
### Prefer System Text Styles
```swift
// GOOD - System styles
.font(.body)
.font(.headline)
.font(.title)
.font(.caption)
// AVOID - Custom sizes that don't scale
.font(.system(size: 14))
// IF you must use custom sizes, use ScaledMetric
@ScaledMetric private var customSize: CGFloat = 14
.font(.system(size: customSize))
```
## VoiceOver
### Accessibility Labels
All interactive elements must have meaningful labels:
```swift
// GOOD - Descriptive label
Button { } label: {
Image(systemName: "trash")
}
.accessibilityLabel("Delete item")
// GOOD - Context-aware label
Button { } label: {
Image(systemName: "heart.fill")
}
.accessibilityLabel(item.isFavorite ? "Remove from favorites" : "Add to favorites")
```
### Accessibility Values
Use for dynamic state that changes:
```swift
Slider(value: $volume)
.accessibilityLabel("Volume")
.accessibilityValue("\(Int(volume * 100)) percent")
Toggle(isOn: $isEnabled) {
Text("Notifications")
}
.accessibilityValue(isEnabled ? "On" : "Off")
```
### Accessibility Hints
Describe what happens when the user interacts:
```swift
Button("Submit") { }
.accessibilityLabel("Submit order")
.accessibilityHint("Double-tap to place your order and proceed to payment")
NavigationLink(value: item) {
ItemRow(item: item)
}
.accessibilityHint("Opens item details")
```
### Accessibility Traits
Use traits to convey element type and behavior:
```swift
// Button trait (usually automatic)
Text("Tap me")
.onTapGesture { }
.accessibilityAddTraits(.isButton)
// Header trait for section headers
Text("Settings")
.font(.headline)
.accessibilityAddTraits(.isHeader)
// Selected state
ItemRow(item: item)
.accessibilityAddTraits(isSelected ? .isSelected : [])
// Image trait removal for decorative images
Image("decorative-background")
.accessibilityHidden(true)
```
### Hide Decorative Elements
Hide elements that don't provide meaningful information:
```swift
// Decorative separator
Divider()
.accessibilityHidden(true)
// Background decoration
Image("pattern")
.accessibilityHidden(true)
// Redundant icon next to text
HStack {
Image(systemName: "envelope")
.accessibilityHidden(true) // Label conveys the meaning
Text("Email")
}
```
### Group Related Elements
Reduce navigation complexity by grouping related content:
```swift
// GOOD - Single VoiceOver element
HStack {
Image(systemName: "person")
VStack(alignment: .leading) {
Text(user.name)
Text(user.email)
.font(.caption)
}
}
.accessibilityElement(children: .combine)
// OR create a completely custom accessibility representation
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(user.name), \(user.email)")
```
### Accessibility Actions
Add custom actions for complex interactions:
```swift
ItemRow(item: item)
.accessibilityAction(named: "Delete") {
deleteItem(item)
}
.accessibilityAction(named: "Edit") {
editItem(item)
}
.accessibilityAction(named: "Share") {
shareItem(item)
}
```
### Accessibility Announcements
Announce important state changes:
```swift
func completeTask() {
task.isCompleted = true
// Announce the change
AccessibilityNotification.Announcement("Task completed")
.post()
}
func showError(_ message: String) {
errorMessage = message
// Announce errors immediately
AccessibilityNotification.Announcement(message)
.post()
}
```
### Accessibility Focus
Control focus for important UI changes:
```swift
struct ContentView: View {
@AccessibilityFocusState private var isSearchFocused: Bool
@State private var showingSearch = false
var body: some View {
VStack {
if showingSearch {
TextField("Search", text: $searchText)
.accessibilityFocused($isSearchFocused)
}
Button("Search") {
showingSearch = true
isSearchFocused = true // Move focus to search field
}
}
}
}
```
## Common Patterns
### Cards and List Items
```swift
struct ItemCard: View {
let item: Item
var body: some View {
VStack(alignment: .leading) {
Text(item.title)
.font(.headline)
Text(item.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
HStack {
Label("\(item.likes)", systemImage: "heart")
Label("\(item.comments)", systemImage: "bubble.right")
}
.font(.caption)
}
// Combine into single VoiceOver element
.accessibilityElement(children: .combine)
// Add meaningful summary
.accessibilityLabel("\(item.title), \(item.subtitle)")
.accessibilityValue("\(item.likes) likes, \(item.comments) comments")
}
}
```
### Interactive Charts
```swift
Chart {
ForEach(data) { point in
LineMark(x: .value("Date", point.date), y: .value("Value", point.value))
}
}
.accessibilityLabel("Sales chart")
.accessibilityValue("Showing data from \(startDate) to \(endDate)")
.accessibilityHint("Swipe up or down to hear individual data points")
.accessibilityChartDescriptor(self)
```
### Custom Controls
```swift
struct RatingControl: View {
@Binding var rating: Int
var body: some View {
HStack {
ForEach(1...5, id: \.self) { star in
Image(systemName: star <= rating ? "star.fill" : "star")
.onTapGesture { rating = star }
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Rating")
.accessibilityValue("\(rating) of 5 stars")
.accessibilityAdjustableAction { direction in
switch direction {
case .increment:
rating = min(5, rating + 1)
case .decrement:
rating = max(1, rating - 1)
@unknown default:
break
}
}
}
}
```
## Testing Accessibility
### Enable VoiceOver in Simulator
1. Settings → Accessibility → VoiceOver
2. Or use Accessibility Inspector (Xcode → Open Developer Tool)
### Audit Checklist
- [ ] All interactive elements have labels
- [ ] Dynamic content announces changes
- [ ] Decorative elements are hidden
- [ ] Text scales with Dynamic Type
- [ ] Touch targets are at least 44pt
- [ ] Color is not the only indicator of state
- [ ] Groups reduce navigation complexity

View File

@ -0,0 +1,393 @@
---
name: Modern SwiftUI
description: Modern SwiftUI API usage and best practices
globs: ["**/*.swift"]
---
# Modern SwiftUI Patterns
Use modern SwiftUI APIs and avoid deprecated patterns.
## Styling APIs
### Use foregroundStyle() Not foregroundColor()
```swift
// BAD - Deprecated
Text("Hello")
.foregroundColor(.blue)
// GOOD
Text("Hello")
.foregroundStyle(.blue)
// GOOD - With gradients
Text("Hello")
.foregroundStyle(.linearGradient(colors: [.blue, .purple], startPoint: .leading, endPoint: .trailing))
```
### Use clipShape(.rect()) Not cornerRadius()
```swift
// BAD - Deprecated
Image("photo")
.cornerRadius(12)
// GOOD
Image("photo")
.clipShape(.rect(cornerRadius: 12))
// GOOD - With specific corners
Image("photo")
.clipShape(.rect(cornerRadii: .init(topLeading: 12, topTrailing: 12)))
```
### Use bold() Not fontWeight(.bold)
```swift
// Less preferred
Text("Title")
.fontWeight(.bold)
// Preferred
Text("Title")
.bold()
```
## Navigation
### Use NavigationStack with navigationDestination
```swift
// BAD - Old NavigationView with NavigationLink
NavigationView {
List(items) { item in
NavigationLink(destination: DetailView(item: item)) {
ItemRow(item: item)
}
}
}
// GOOD - NavigationStack with typed destinations
NavigationStack {
List(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
}
}
```
### Use NavigationPath for Programmatic Navigation
```swift
@Observable
@MainActor
final class NavigationStore {
var path = NavigationPath()
func navigate(to item: Item) {
path.append(item)
}
func popToRoot() {
path.removeLast(path.count)
}
}
```
## Tab View
### Use Tab API Not tabItem()
```swift
// BAD - Old tabItem pattern
TabView {
HomeView()
.tabItem {
Label("Home", systemImage: "house")
}
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
}
// GOOD - Tab API (iOS 18+)
TabView {
Tab("Home", systemImage: "house") {
HomeView()
}
Tab("Settings", systemImage: "gear") {
SettingsView()
}
}
```
## Observable Pattern
### Use @Observable Not ObservableObject
```swift
// BAD - Old Combine-based pattern
class FeatureStore: ObservableObject {
@Published var items: [Item] = []
}
struct FeatureView: View {
@StateObject var store = FeatureStore()
// or @ObservedObject
}
// GOOD - Modern Observation
@Observable
@MainActor
final class FeatureStore {
var items: [Item] = []
}
struct FeatureView: View {
@State var store = FeatureStore()
// or for external injection:
@Bindable var store: FeatureStore
}
```
## Event Handling
### Use Button Not onTapGesture()
```swift
// BAD - No accessibility, no button styling
Text("Submit")
.onTapGesture {
submit()
}
// GOOD - Proper button semantics
Button("Submit") {
submit()
}
// When you need tap location/count, onTapGesture is acceptable
SomeView()
.onTapGesture(count: 2) { location in
handleDoubleTap(at: location)
}
```
### Use Two-Parameter onChange()
```swift
// BAD - Deprecated single parameter
.onChange(of: searchText) { newValue in
search(for: newValue)
}
// GOOD - Two parameter version
.onChange(of: searchText) { oldValue, newValue in
search(for: newValue)
}
// GOOD - When you don't need old value
.onChange(of: searchText) { _, newValue in
search(for: newValue)
}
```
## Layout
### Avoid UIScreen.main.bounds
```swift
// BAD - Hardcoded screen size
let width = UIScreen.main.bounds.width
// GOOD - GeometryReader when needed
GeometryReader { geometry in
SomeView()
.frame(width: geometry.size.width * 0.8)
}
// BETTER - containerRelativeFrame (iOS 17+)
SomeView()
.containerRelativeFrame(.horizontal) { size, _ in
size * 0.8
}
```
### Prefer containerRelativeFrame Over GeometryReader
```swift
// Avoid GeometryReader when possible
ScrollView(.horizontal) {
LazyHStack {
ForEach(items) { item in
ItemCard(item: item)
.containerRelativeFrame(.horizontal, count: 3, spacing: 16)
}
}
}
```
## View Composition
### Extract to View Structs Not Computed Properties
```swift
// BAD - Computed properties for view composition
struct ContentView: View {
private var header: some View {
HStack {
Text("Title")
Spacer()
Button("Action") { }
}
}
var body: some View {
VStack {
header
// ...
}
}
}
// GOOD - Separate View struct
struct HeaderView: View {
let title: String
let action: () -> Void
var body: some View {
HStack {
Text(title)
Spacer()
Button("Action", action: action)
}
}
}
```
### Avoid AnyView
```swift
// BAD - Type erasure loses optimization
func makeView(for type: ViewType) -> AnyView {
switch type {
case .list: return AnyView(ListView())
case .grid: return AnyView(GridView())
}
}
// GOOD - @ViewBuilder
@ViewBuilder
func makeView(for type: ViewType) -> some View {
switch type {
case .list: ListView()
case .grid: GridView()
}
}
```
## Lists and ForEach
### Don't Convert to Array for Enumeration
```swift
// BAD - Unnecessary Array conversion
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
// ...
}
// GOOD - Use indices or zip
ForEach(items.indices, id: \.self) { index in
let item = items[index]
// ...
}
// GOOD - If you need both
ForEach(Array(zip(items.indices, items)), id: \.0) { index, item in
// ...
}
```
### Hide Scroll Indicators
```swift
// Use scrollIndicators modifier
ScrollView {
// content
}
.scrollIndicators(.hidden)
```
## Button Labels with Images
### Always Include Text with Image Buttons
```swift
// BAD - No accessibility label
Button {
addItem()
} label: {
Image(systemName: "plus")
}
// GOOD - Text alongside image
Button {
addItem()
} label: {
Label("Add Item", systemImage: "plus")
}
// GOOD - If you only want to show the image
Button {
addItem()
} label: {
Label("Add Item", systemImage: "plus")
.labelStyle(.iconOnly)
}
```
## Design Constants
### Never Use Raw Numeric Literals
```swift
// BAD
.padding(16)
.clipShape(.rect(cornerRadius: 12))
.opacity(0.7)
// GOOD - Use design constants
.padding(Design.Spacing.medium)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.opacity(Design.Opacity.strong)
```
### Never Use Inline Colors
```swift
// BAD
.foregroundStyle(Color(red: 0.2, green: 0.4, blue: 0.8))
.background(Color(hex: "#3366CC"))
// GOOD - Semantic color names
.foregroundStyle(Color.Theme.primary)
.background(Color.Background.secondary)
```
## Image Rendering
### Prefer ImageRenderer Over UIGraphicsImageRenderer
```swift
// For SwiftUI → Image conversion
let renderer = ImageRenderer(content: MyView())
if let uiImage = renderer.uiImage {
// use image
}
```

View File

@ -0,0 +1,228 @@
---
name: SwiftUI MVVM
description: View/State separation patterns for SwiftUI with Observable stores
globs: ["**/*.swift"]
---
# View/State Separation (MVVM-lite)
**Views should be "dumb" renderers.** All business logic belongs in stores or dedicated view models.
## What Belongs in State/Store
- **Business logic**: Calculations, validations, rules
- **Computed properties based on data**: Hints, recommendations, derived values
- **State checks**: `canSubmit`, `isLoading`, `hasError`
- **Data transformations**: Filtering, sorting, aggregations
- **Side effects**: Network calls, persistence, analytics
## What is Acceptable in Views
- **Pure UI layout logic**: Adaptive layouts based on size class
- **Visual styling**: Color selection based on state
- **@ViewBuilder sub-views**: Breaking up complex layouts (keep in same file if small)
- **Accessibility labels**: Combining data into accessible descriptions
- **Simple conditionals for UI**: `if isExpanded { ... }`
## Store Pattern
```swift
@Observable
@MainActor
final class FeatureStore {
// MARK: - State
private(set) var items: [Item] = []
private(set) var isLoading = false
private(set) var error: Error?
// MARK: - Computed Properties (Business Logic)
var isEmpty: Bool { items.isEmpty }
var itemCount: Int { items.count }
var canSubmit: Bool { !selectedItems.isEmpty && !isLoading }
var filteredItems: [Item] {
guard !searchText.isEmpty else { return items }
return items.filter { $0.name.localizedStandardContains(searchText) }
}
// MARK: - User Input
var searchText = ""
var selectedItems: Set<Item.ID> = []
// MARK: - Dependencies
private let service: FeatureServiceProtocol
init(service: FeatureServiceProtocol) {
self.service = service
}
// MARK: - Actions
func load() async {
isLoading = true
defer { isLoading = false }
do {
items = try await service.fetchItems()
} catch {
self.error = error
}
}
func submit() async {
guard canSubmit else { return }
// ...
}
}
```
## View Pattern
```swift
struct FeatureView: View {
@Bindable var store: FeatureStore
var body: some View {
List(store.filteredItems) { item in
ItemRow(item: item)
}
.searchable(text: $store.searchText)
.overlay {
if store.isEmpty {
ContentUnavailableView("No Items", systemImage: "tray")
}
}
.toolbar {
Button("Submit") {
Task { await store.submit() }
}
.disabled(!store.canSubmit)
}
.task {
await store.load()
}
}
}
```
## Bad vs Good Examples
### Validation Logic
```swift
// BAD - Business logic in view
struct MyView: View {
@Bindable var store: FeatureStore
private var isValid: Bool {
!store.name.isEmpty && store.email.contains("@")
}
var body: some View {
Button("Save") { }
.disabled(!isValid)
}
}
// GOOD - Logic in Store, view just reads
// In FeatureStore:
var isValid: Bool {
!name.isEmpty && email.contains("@")
}
// In View:
Button("Save") { store.save() }
.disabled(!store.isValid)
```
### Filtering Logic
```swift
// BAD - Filtering in view
struct ListView: View {
@Bindable var store: ListStore
var filteredItems: [Item] {
store.items.filter { $0.isActive && $0.category == selectedCategory }
}
var body: some View {
List(filteredItems) { ... }
}
}
// GOOD - Filtering in Store
// In ListStore:
var filteredItems: [Item] {
items.filter { $0.isActive && $0.category == selectedCategory }
}
// In View:
List(store.filteredItems) { ... }
```
### Error Handling
```swift
// BAD - Error formatting in view
struct ProfileView: View {
@Bindable var store: ProfileStore
var errorMessage: String? {
guard let error = store.error else { return nil }
if let networkError = error as? NetworkError {
switch networkError {
case .notFound: return "Profile not found"
case .unauthorized: return "Please sign in"
default: return "Something went wrong"
}
}
return error.localizedDescription
}
}
// GOOD - Error message in Store
// In ProfileStore:
var errorMessage: String? {
guard let error else { return nil }
return ErrorFormatter.message(for: error)
}
// In View:
if let message = store.errorMessage {
Text(message)
}
```
## When to Create a Separate ViewModel
Use a dedicated ViewModel (instead of a Store) when:
1. The view has complex local state that doesn't need to persist
2. You need to transform data from multiple stores for a single view
3. The view has form validation with many fields
4. You're wrapping a UIKit component that needs state management
```swift
@Observable
@MainActor
final class FormViewModel {
// Form-specific state
var firstName = ""
var lastName = ""
var email = ""
// Validation
var isValid: Bool {
!firstName.isEmpty && !lastName.isEmpty && email.contains("@")
}
var firstNameError: String? {
firstName.isEmpty ? "First name is required" : nil
}
// Submit creates domain object
func createUser() -> User {
User(firstName: firstName, lastName: lastName, email: email)
}
}
```