Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Generate FOSMVVM ViewModels for SwiftUI screens, pages, and components. Scaffolds RequestableViewModel, localization bindings, and stub factories.
.claude/skills/fosmvvm-viewmodel-generator/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-21 | ✗→✓ | ▲ Improved | — | — |
| case-07 | ✗→✓ | ▲ Improved | — | — |
| case-13 | ✗→✓ | ▲ Improved | — | — |
| case-08 | ✗→✓ | ▲ Improved | — | — |
| case-12 | ✗→✓ | ▲ Improved | — | — |
Generate ViewModels following FOSMVVM architecture patterns.
> For full architecture context, see FOSMVVMArchitecture.md | OpenClaw reference
A ViewModel is the bridge in the Model-View-ViewModel architecture:
┌─────────────┐ ┌─────────────────┐ ┌─────────────┐
│ Model │ ───► │ ViewModel │ ───► │ View │
│ (Data) │ │ (The Bridge) │ │ (SwiftUI) │
└─────────────┘ └─────────────────┘ └─────────────┘Key insight: In FOSMVVM, ViewModels are:
@LocalizedString references)This is a per-ViewModel decision. An app can mix both modes - for example, a standalone iPhone app with server-based sign-in.
The key question: Where does THIS ViewModel's data come from?
| Data Source | Hosting Mode | Factory | |-------------|--------------|---------| | Server/Database | Server-Hosted | Hand-written | | Local state/preferences | Client-Hosted | Macro-generated | | ResponseError (caught error) | Client-Hosted | Macro-generated |
When data comes from a server:
ViewModelFactory protocol)Examples: Sign-in screen, user profile from API, dashboard with server data
When data is local to the device:
@ViewModel(options: [.clientHostedFactory])Examples: Settings screen, onboarding, offline-first features, error display
Error display is a classic client-hosted scenario. You already have the data from ResponseError - just wrap it in a specific ViewModel for that error:
swift// Specific ViewModel for MoveIdeaRequest errors @ViewModel(options: [.clientHostedFactory]) struct MoveIdeaErrorViewModel { let message: LocalizableString let errorCode: String public var vmId = ViewModelId() // Takes the specific ResponseError init(responseError: MoveIdeaRequest.ResponseError) { self.message = responseError.message self.errorCode = responseError.code.rawValue } }
Usage:
swiftcatch let error as MoveIdeaRequest.ResponseError { let vm = MoveIdeaErrorViewModel(responseError: error) return try await req.view.render("Shared/ToastView", vm) }
Each error scenario gets its own ViewModel:
MoveIdeaErrorViewModel for MoveIdeaRequest.ResponseErrorCreateIdeaErrorViewModel for CreateIdeaRequest.ResponseErrorSettingsValidationErrorViewModel for settings form errorsDon't create a generic "ToastViewModel" or "ErrorViewModel" - that's unified error architecture, which we avoid.
Key insights:
LocalizableString properties in ResponseError are already localized (server did it)Many apps use both:
┌───────────────────────────────────────────────┐
│ iPhone App │
├───────────────────────────────────────────────┤
│ SettingsViewModel → Client-Hosted │
│ OnboardingViewModel → Client-Hosted │
│ MoveIdeaErrorViewModel → Client-Hosted │ ← Error display
│ SignInViewModel → Server-Hosted │
│ UserProfileViewModel → Server-Hosted │
└───────────────────────────────────────────────┘Same ViewModel patterns work in both modes - only the factory creation differs.
A ViewModel's job is shaping data for presentation. This happens in two places:
The View just renders - it should never compose, format, or reorder ViewModel properties.
A ViewModel answers: "What does the View need to display?"
| Content Type | How It's Represented | Example | |--------------|---------------------|---------| | Static UI text | @LocalizedString | Page titles, button labels (fixed text) | | Dynamic enum values | LocalizableString (stored) | Status/state display (see Enum Localization Pattern) | | Dynamic data in text | @LocalizedSubs | "Welcome, %{name}!" with substitutions | | Composed text | @LocalizedCompoundString | Full name from pieces (locale-aware order) | | Formatted dates | LocalizableDate | createdAt: LocalizableDate | | Formatted numbers | LocalizableInt | totalCount: LocalizableInt | | Dynamic data | Plain properties | content: String, count: Int | | Nested components | Child ViewModels | cards: [CardViewModel] |
@Parent, @Siblings)swift// ❌ WRONG - View is composing Text(viewModel.firstName) + Text(" ") + Text(viewModel.lastName) // ✅ RIGHT - ViewModel provides shaped result Text(viewModel.fullName) // via @LocalizedCompoundString
If you see + or string interpolation in a View, the shaping belongs in the ViewModel.
swiftpublic protocol ViewModel: ServerRequestBody, RetrievablePropertyNames, Identifiable, Stubbable { var vmId: ViewModelId { get } } public protocol RequestableViewModel: ViewModel { associatedtype Request: ViewModelRequest }
ViewModel provides:
ServerRequestBody - Can be sent over HTTP as JSONRetrievablePropertyNames - Enables @LocalizedString binding (via @ViewModel macro)Identifiable - Has vmId for SwiftUI identityStubbable - Has stub() for testing/previewsRequestableViewModel adds:
Request type for fetching from serverRepresents a full page or screen. Has:
ViewModelRequest typeViewModelFactory that builds it from databaseswift@ViewModel public struct DashboardViewModel: RequestableViewModel { public typealias Request = DashboardRequest @LocalizedString public var pageTitle public let cards: [CardViewModel] // Children public var vmId: ViewModelId = .init() }
Nested components built by their parent's factory. No Request type.
swift@ViewModel public struct CardViewModel: Codable, Sendable { public let id: ModelIdType public let title: String public let createdAt: LocalizableDate public var vmId: ViewModelId = .init() }
ViewModels serve two distinct purposes:
| Purpose | ViewModel Type | Adopts Fields? | |---------|----------------|----------------| | Display data (read-only) | Display ViewModel | No | | Collect user input (editable) | Form ViewModel | Yes |
For showing data - cards, rows, lists, detail views:
swift@ViewModel public struct UserCardViewModel { public let id: ModelIdType public let name: String @LocalizedString public var roleDisplayName public let createdAt: LocalizableDate public var vmId: ViewModelId = .init() }
Characteristics:
let (read-only)For collecting input - create forms, edit forms, settings:
swift@ViewModel public struct UserFormViewModel: UserFields { // ← Adopts Fields! public var id: ModelIdType? public var email: String public var firstName: String public var lastName: String public let userValidationMessages: UserFieldsMessages public var vmId: ViewModelId = .init() }
Characteristics:
var (editable)┌─────────────────────────────────────────────────────────────────┐
│ UserFields Protocol │
│ (defines editable properties + validation) │
│ │
│ Adopted by: │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ CreateUserReq │ │ UserFormVM │ │ User (Model) │ │
│ │ .RequestBody │ │ (UI form) │ │ (persistence) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
│ Same validation logic everywhere! │
└─────────────────────────────────────────────────────────────────┘The key question: "Is the user editing data in this ViewModel?"
| ViewModel | User Edits? | Adopt Fields? | |-----------|-------------|---------------| | UserCardViewModel | No | No | | UserRowViewModel | No | No | | UserDetailViewModel | No | No | | UserFormViewModel | Yes | UserFields | | CreateUserViewModel | Yes | UserFields | | EditUserViewModel | Yes | UserFields | | SettingsViewModel | Yes | SettingsFields |
| File | Location | Purpose | |------|----------|---------| | {Name}ViewModel.swift | {ViewModelsTarget}/ | The ViewModel struct | | {Name}Request.swift | {ViewModelsTarget}/ | The ViewModelRequest type | | {Name}ViewModel.yml | {ResourcesPath}/ | Localization strings | | {Name}ViewModel+Factory.swift | {WebServerTarget}/ | Factory that builds from DB |
| File | Location | Purpose | |------|----------|---------| | {Name}ViewModel.swift | {ViewModelsTarget}/ | ViewModel with clientHostedFactory option | | {Name}ViewModel.yml | {ResourcesPath}/ | Localization strings (bundled in app) |
No Request or Factory files needed - macro generates them!
| File | Location | Purpose | |------|----------|---------| | {Name}ViewModel.swift | {ViewModelsTarget}/ | The ViewModel struct | | {Name}ViewModel.yml | {ResourcesPath}/ | Localization (if has @LocalizedString) |
Note: If child is only used by one parent and represents a summary/reference (not a full ViewModel), nest it inside the parent file instead. See Nested Child Types Pattern under Key Patterns.
| Placeholder | Description | Example | |-------------|-------------|---------| | {ViewModelsTarget} | Shared ViewModels SPM target | ViewModels | | {ResourcesPath} | Localization resources | Sources/Resources | | {WebServerTarget} | Server-side target | WebServer, AppServer |
Invocation: /fosmvvm-viewmodel-generator
Prerequisites:
Workflow integration: This skill is typically used after discussing View requirements or reading specification files. The skill references conversation context automatically—no file paths or Q&A needed. For Form ViewModels, run fosmvvm-fields-generator first to create the Fields protocol.
This skill references conversation context to determine ViewModel structure:
From conversation context, the skill identifies:
From requirements already in context:
Based on View requirements:
Server-Hosted Top-Level:
RequestableViewModel)Client-Hosted Top-Level:
clientHostedFactory option)Child (either mode):
Skill references information from:
Always use the @ViewModel macro - it generates the propertyNames() method required for localization binding.
Server-Hosted (basic macro):
swift@ViewModel public struct MyViewModel: RequestableViewModel { public typealias Request = MyRequest @LocalizedString public var title public var vmId: ViewModelId = .init() public init() {} }
Client-Hosted (with factory generation):
swift@ViewModel(options: [.clientHostedFactory]) public struct SettingsViewModel { @LocalizedString public var pageTitle public var vmId: ViewModelId = .init() public init(theme: Theme, notifications: NotificationSettings) { // Init parameters become AppState properties } } // Macro auto-generates: // - typealias Request = ClientHostedRequest // - struct AppState { let theme: Theme; let notifications: NotificationSettings } // - class ClientHostedRequest: ViewModelRequest { ... } // - static func model(context:) async throws -> Self { ... }
All ViewModels must support stub() for testing and SwiftUI previews:
swiftpublic extension MyViewModel { static func stub() -> Self { .init(/* default values */) } }
Every ViewModel needs a vmId for SwiftUI's identity system:
Singleton (one per page): vmId = .init(type: Self.self) Instance (multiple per page): vmId = .init(id: id) where id: ModelIdType
Static UI text uses @LocalizedString:
swift@LocalizedString public var pageTitle
With corresponding YAML:
yamlen: MyViewModel: pageTitle: "Welcome"
Never send pre-formatted strings. Use localizable types:
swiftpublic let createdAt: LocalizableDate // NOT String public let itemCount: LocalizableInt // NOT String
The client formats these according to user's locale and timezone.
For dynamic enum values (status, state, category), use a stored LocalizableString - NOT @LocalizedString.
@LocalizedString always looks up the same key (the property name). A stored LocalizableString carries the dynamic key from the enum case.
swift// Enum provides localizableString public enum SessionState: String, CaseIterable, Codable, Sendable { case pending, running, completed, failed public var localizableString: LocalizableString { .localized(for: Self.self, propertyName: rawValue) } } // ViewModel stores it (NOT @LocalizedString) @ViewModel public struct SessionCardViewModel { public let state: SessionState // Raw enum for data attributes public let stateDisplay: LocalizableString // Localized display text public init(session: Session) { self.state = session.state self.stateDisplay = session.state.localizableString } }
yaml# YAML keys match enum type and case names en: SessionState: pending: "Pending" running: "Running" completed: "Completed" failed: "Failed"
Constraint: LocalizableString only works in ViewModels encoded with localizingEncoder(). Do not use in Fluent JSONB fields or other persisted types.
Top-level ViewModels contain their children:
swift@ViewModel public struct BoardViewModel: RequestableViewModel { public let columns: [ColumnViewModel] public let cards: [CardViewModel] }
The Factory builds all children when building the parent.
When a child type is only used by one parent and represents a summary or reference (not a full ViewModel), nest it inside the parent:
swift@ViewModel public struct GovernancePrincipleCardViewModel: Codable, Sendable, Identifiable { // Properties come first public let versionHistory: [GovernancePrincipleVersionSummary]? public let referencingDecisions: [GovernanceDecisionReference]? // MARK: - Nested Types /// Summary of a principle version for display in version history. public struct GovernancePrincipleVersionSummary: Codable, Sendable, Identifiable, Stubbable { public let id: ModelIdType public let version: Int public let createdAt: Date public init(id: ModelIdType, version: Int, createdAt: Date) { self.id = id self.version = version self.createdAt = createdAt } } /// Reference to a decision that cites this principle. public struct GovernanceDecisionReference: Codable, Sendable, Identifiable, Stubbable { public let id: ModelIdType public let title: String public let decisionNumber: String public let createdAt: Date public init(id: ModelIdType, title: String, decisionNumber: String, createdAt: Date) { self.id = id self.title = title self.decisionNumber = decisionNumber self.createdAt = createdAt } } // vmId and parent init follow public let vmId: ViewModelId // ... }
Reference: Sources/KairosModels/Governance/GovernancePrincipleCardViewModel.swift
Placement rules:
vmId and the parent's init// MARK: - Nested Types section markerConformances for nested types:
Codable - for ViewModel encodingSendable - for Swift 6 concurrencyIdentifiable - for SwiftUI ForEach if used in arraysStubbable - for testing/previewsTwo-Tier Stubbable Pattern:
Nested types use fully qualified names in their extensions:
swiftpublic extension GovernancePrincipleCardViewModel.GovernancePrincipleVersionSummary { // Tier 1: Zero-arg convenience (ALWAYS delegates to tier 2) static func stub() -> Self { .stub(id: .init()) } // Tier 2: Full parameterized with defaults static func stub( id: ModelIdType = .init(), version: Int = 1, createdAt: Date = .now ) -> Self { .init(id: id, version: version, createdAt: createdAt) } } public extension GovernancePrincipleCardViewModel.GovernanceDecisionReference { static func stub() -> Self { .stub(id: .init()) } static func stub( id: ModelIdType = .init(), title: String = "A Title", decisionNumber: String = "DEC-12345", createdAt: Date = .now ) -> Self { .init(id: id, title: title, decisionNumber: decisionNumber, createdAt: createdAt) } }
Why two tiers:
[.stub()] without caring about values.stub(name: "Specific Name")When to nest vs keep top-level:
| Nest Inside Parent | Keep Top-Level | |-------------------|----------------| | Child is ONLY used by this parent | Child is shared across multiple parents | | Child represents subset/summary | Child is a full ViewModel | | Child has no @ViewModel macro | Child has @ViewModel macro | | Child is not RequestableViewModel | Child is RequestableViewModel | | Example: VersionSummary, Reference | Example: CardViewModel, ListViewModel |
Examples:
Card with nested summaries:
swift@ViewModel public struct TaskCardViewModel { public let assignees: [AssigneeSummary]? public struct AssigneeSummary: Codable, Sendable, Identifiable, Stubbable { public let id: ModelIdType public let name: String public let avatarUrl: String? // ... } }
List with nested references:
swift@ViewModel public struct ProjectListViewModel { public let relatedProjects: [ProjectReference]? public struct ProjectReference: Codable, Sendable, Identifiable, Stubbable { public let id: ModelIdType public let title: String public let status: String // ... } }
Swift's synthesized Codable only encodes stored properties. Since ViewModels are serialized (for JSON transport, Leaf rendering, etc.), computed properties won't be available.
swift// Computed - NOT encoded, invisible after serialization public var hasCards: Bool { !cards.isEmpty } // Stored - encoded, available after serialization public let hasCards: Bool
When to pre-compute:
For Leaf templates, you can often use Leaf's built-in functions directly:
#if(count(cards) > 0) - no need for hasCards property#count(cards) - no need for cardCount propertyPre-compute only when:
firstCard - array indexing not documented in Leaf)See fosmvvm-leaf-view-generator for Leaf template patterns.
See reference.md for complete file templates.
| Concept | Convention | Example | |---------|------------|---------| | ViewModel struct | {Name}ViewModel | DashboardViewModel | | Request class | {Name}Request | DashboardRequest | | Factory extension | {Name}ViewModel+Factory.swift | DashboardViewModel+Factory.swift | | YAML file | {Name}ViewModel.yml | DashboardViewModel.yml |
| Version | Date | Changes | |---------|------|---------| | 1.0 | 2024-12-24 | Initial skill | | 2.0 | 2024-12-26 | Complete rewrite from architecture; generalized from Kairos-specific | | 2.1 | 2024-12-26 | Added Client-Hosted mode support; per-ViewModel hosting decision | | 2.2 | 2024-12-26 | Added shaping responsibility, @LocalizedSubs/@LocalizedCompoundString, anti-pattern | | 2.3 | 2025-12-27 | Added Display vs Form ViewModels section; clarified Fields adoption | | 2.4 | 2026-01-08 | Added Codable/computed properties section. Clarified when to pre-compute vs use Leaf built-ins. | | 2.5 | 2026-01-19 | Added Enum Localization Pattern section. Clarified @LocalizedString is for static text only; stored LocalizableString for dynamic enum values. | | 2.6 | 2026-01-24 | Update to context-aware approach (remove file-parsing/Q&A). Skill references conversation context instead of asking questions or accepting file paths. | | 2.7 | 2026-01-25 | Added Nested Child Types Pattern section with two-tier Stubbable pattern, placement rules, conformances, and decision criteria for when to nest vs keep top-level. |
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-21 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +68 percentage points is the difference between those two pass rates over the 22 comparable cases.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.