fosmvvm-swiftui-view-generator
Generate SwiftUI views that render FOSMVVM ViewModels. Scaffolds ViewModelView pattern with binding, loading states, and previews.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install openclaw-skills-fosmvvm-swiftui-view-generator
Repository
Skill path: skills/foscomputerservices/fosmvvm-swiftui-view-generator
Generate SwiftUI views that render FOSMVVM ViewModels. Scaffolds ViewModelView pattern with binding, loading states, and previews.
Open repositoryBest for
Primary workflow: Ship Full Stack.
Technical facets: Full Stack.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: openclaw.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install fosmvvm-swiftui-view-generator into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/openclaw/skills before adding fosmvvm-swiftui-view-generator to shared team environments
- Use fosmvvm-swiftui-view-generator for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: fosmvvm-swiftui-view-generator
description: Generate SwiftUI views that render FOSMVVM ViewModels. Scaffolds ViewModelView pattern with binding, loading states, and previews.
homepage: https://github.com/foscomputerservices/FOSUtilities
metadata: {"clawdbot": {"emoji": "π±", "os": ["darwin"]}}
---
# FOSMVVM SwiftUI View Generator
Generate SwiftUI views that render FOSMVVM ViewModels.
## Conceptual Foundation
> For full architecture context, see [FOSMVVMArchitecture.md](../../docs/FOSMVVMArchitecture.md) | [OpenClaw reference]({baseDir}/references/FOSMVVMArchitecture.md)
In FOSMVVM, **Views are thin rendering layers** that display ViewModels:
```
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ViewModelView Pattern β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β ViewModel (Data) ViewModelView (SwiftUI) β
β ββββββββββββββββββββ ββββββββββββββββββββ β
β β title: String ββββββΊβ Text(vm.title) β β
β β items: [Item] ββββββΊβ ForEach(vm.items)β β
β β isEnabled: Bool ββββββΊβ .disabled(!...) β β
β ββββββββββββββββββββ ββββββββββββββββββββ β
β β
β Operations (Actions) β
β ββββββββββββββββββββ ββββββββββββββββββββ β
β β submit() βββββββ Button(action:) β β
β β cancel() βββββββ .onAppear { } β β
β ββββββββββββββββββββ ββββββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
```
**Key principle:** Views don't transform or compute data. They render what the ViewModel provides.
---
## View-ViewModel Alignment
**The View filename should match the ViewModel it renders.**
```
Sources/
{ViewModelsTarget}/
{Feature}/
{Feature}ViewModel.swift ββββ
{Entity}CardViewModel.swift ββββΌββ Same names
β
{ViewsTarget}/ β
{Feature}/ β
{Feature}View.swift βββββ€ (renders {Feature}ViewModel)
{Entity}CardView.swift βββββ (renders {Entity}CardViewModel)
```
This alignment provides:
- **Discoverability** - Find the view for any ViewModel instantly
- **Consistency** - Same naming discipline across the codebase
- **Maintainability** - Changes to ViewModel are reflected in view location
---
## Core Components
### 1. ViewModelView Protocol
Every view conforms to `ViewModelView`:
```swift
public struct MyView: ViewModelView {
private let viewModel: MyViewModel
public var body: some View {
Text(viewModel.title)
}
public init(viewModel: MyViewModel) {
self.viewModel = viewModel
}
}
```
**Required:**
- `private let viewModel: {ViewModel}`
- `public init(viewModel:)`
- Conforms to `ViewModelView` protocol
### 2. Operations (Optional)
Interactive views have operations:
```swift
public struct MyView: ViewModelView {
private let viewModel: MyViewModel
private let operations: MyViewModelOperations
#if DEBUG
@State private var repaintToggle = false
#endif
public var body: some View {
Button(action: performAction) {
Text(viewModel.buttonLabel)
}
#if DEBUG
.testDataTransporter(viewModelOps: operations, repaintToggle: $repaintToggle)
#endif
}
public init(viewModel: MyViewModel) {
self.viewModel = viewModel
self.operations = viewModel.operations
}
private func performAction() {
operations.performAction()
toggleRepaint()
}
private func toggleRepaint() {
#if DEBUG
repaintToggle.toggle()
#endif
}
}
```
**When views have operations:**
- Store `operations` from `viewModel.operations` in init
- Add `@State private var repaintToggle = false` (DEBUG only)
- Add `.testDataTransporter(viewModelOps:repaintToggle:)` modifier (DEBUG only)
- Call `toggleRepaint()` after every operation invocation
### 3. Child View Binding
Parent views bind child views using `.bind(appState:)`:
```swift
public struct ParentView: ViewModelView {
@Environment(AppState.self) private var appState
private let viewModel: ParentViewModel
public var body: some View {
VStack {
Text(viewModel.title)
// Bind child view with subset of parent's data
ChildView.bind(
appState: .init(
itemId: viewModel.selectedId,
isConnected: viewModel.isConnected
)
)
}
}
}
```
**The `.bind()` pattern:**
- Child views use `.bind(appState:)` to receive data from parent
- Parent creates child's `AppState` from its own ViewModel data
- Enables composition without tight coupling
### 4. Form Views with Validation
Forms use `FormFieldView` and `Validations` environment:
```swift
public struct MyFormView: ViewModelView {
@Environment(Validations.self) private var validations
@Environment(\.focusState) private var focusField
@State private var error: Error?
private let viewModel: MyFormViewModel
private let operations: MyFormViewModelOperations
public var body: some View {
Form {
FormFieldView(
fieldModel: viewModel.$email,
focusField: focusField,
fieldValidator: viewModel.validateEmail,
validations: validations
)
Button(errorBinding: $error, asyncAction: submit) {
Text(viewModel.submitButtonLabel)
}
.disabled(validations.hasError)
}
.onAsyncSubmit {
await submit()
}
.alert(
errorBinding: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
)
}
}
```
**Form patterns:**
- `@Environment(Validations.self)` for validation state
- `FormFieldView` for each input field
- `Button(errorBinding:asyncAction:)` for async actions
- `.disabled(validations.hasError)` on submit button
- Separate handling for validation errors vs general errors
### 5. Previews
Use `.previewHost()` for SwiftUI previews:
```swift
#if DEBUG
#Preview {
MyView.previewHost(
bundle: MyAppResourceAccess.localizationBundle
)
.environment(AppState())
}
#Preview("With Data") {
MyView.previewHost(
bundle: MyAppResourceAccess.localizationBundle,
viewModel: .stub(title: "Preview Title")
)
.environment(AppState())
}
#endif
```
## View Categories
### Display-Only Views
Views that just render data (no user interactions):
```swift
public struct InfoView: ViewModelView {
private let viewModel: InfoViewModel
public var body: some View {
VStack {
Text(viewModel.title)
Text(viewModel.description)
if viewModel.isActive {
Text(viewModel.activeStatusLabel)
}
}
}
public init(viewModel: InfoViewModel) {
self.viewModel = viewModel
}
}
```
**Characteristics:**
- No `operations` property
- No `repaintToggle` or `testDataTransporter`
- Just renders ViewModel properties
- May have conditional rendering based on ViewModel state
### Interactive Views
Views with user actions:
```swift
public struct ActionView: ViewModelView {
@State private var error: Error?
private let viewModel: ActionViewModel
private let operations: ActionViewModelOperations
#if DEBUG
@State private var repaintToggle = false
#endif
public var body: some View {
VStack {
Button(action: performAction) {
Text(viewModel.actionLabel)
}
Button(role: .cancel, action: cancel) {
Text(viewModel.cancelLabel)
}
}
.alert(
errorBinding: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
)
#if DEBUG
.testDataTransporter(viewModelOps: operations, repaintToggle: $repaintToggle)
#endif
}
public init(viewModel: ActionViewModel) {
self.viewModel = viewModel
self.operations = viewModel.operations
}
private func performAction() {
operations.performAction()
toggleRepaint()
}
private func cancel() {
operations.cancel()
toggleRepaint()
}
private func toggleRepaint() {
#if DEBUG
repaintToggle.toggle()
#endif
}
}
```
### Form Views
Views with validated input fields:
- Use `FormFieldView` for each input
- `@Environment(Validations.self)` for validation state
- Button disabled when `validations.hasError`
- Separate error handling for validation vs operation errors
### Container Views
Views that compose child views:
```swift
public struct ContainerView: ViewModelView {
@Environment(AppState.self) private var appState
private let viewModel: ContainerViewModel
private let operations: ContainerViewModelOperations
public var body: some View {
VStack {
switch viewModel.state {
case .loading:
ProgressView()
case .ready:
ChildAView.bind(
appState: .init(id: viewModel.selectedId)
)
ChildBView.bind(
appState: .init(
isActive: viewModel.isActive,
level: viewModel.level
)
)
}
}
}
}
```
## When to Use This Skill
- Creating a new SwiftUI view for a FOSMVVM app
- Building UI to render a ViewModel
- Following an implementation plan that requires new views
- Creating forms with validation
- Building container views that compose child views
## What This Skill Generates
| File | Location | Purpose |
|------|----------|---------|
| `{ViewName}View.swift` | `Sources/{ViewsTarget}/{Feature}/` | The SwiftUI view |
**Note:** The corresponding ViewModel and ViewModelOperations should already exist (use `fosmvvm-viewmodel-generator` skill).
## Project Structure Configuration
| Placeholder | Description | Example |
|-------------|-------------|---------|
| `{ViewName}` | View name (without "View" suffix) | `TaskList`, `SignIn` |
| `{ViewsTarget}` | SwiftUI views SPM target | `MyAppViews` |
| `{Feature}` | Feature/module grouping | `Tasks`, `Auth` |
## Pattern Implementation
This skill references conversation context to determine view structure:
### View Type Detection
From conversation context, the skill identifies:
- **ViewModel structure** (from prior discussion or specifications read by Claude)
- **View category**: Display-only, interactive, form, or container
- **Operations needed**: Whether view has user-initiated actions
- **Child composition**: Whether view binds child views
### Component Selection
Based on view type:
- **Display-only**: ViewModelView protocol, viewModel property only
- **Interactive**: Add operations, repaintToggle, testDataTransporter, toggleRepaint()
- **Form**: Add Validations environment, FormFieldView, validation error handling
- **Container**: Add child view `.bind()` calls
### Code Generation
Generates view file with:
1. `ViewModelView` protocol conformance
2. Properties (viewModel, operations if needed, repaintToggle if interactive)
3. Body with rendering logic
4. Init storing viewModel and operations
5. Action methods (if interactive)
6. Test infrastructure (if interactive)
7. Previews for different states
### Context Sources
Skill references information from:
- **Prior conversation**: Requirements discussed with user
- **Specification files**: If Claude has read specifications into context
- **ViewModel definitions**: From codebase or discussion
## Key Patterns
### Error Handling Pattern
```swift
@State private var error: Error?
var body: some View {
VStack {
Button(errorBinding: $error, asyncAction: submit) {
Text(viewModel.submitLabel)
}
}
.alert(
errorBinding: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
)
}
private func submit() async {
do {
try await operations.submit()
} catch {
self.error = error
}
toggleRepaint()
}
```
### Validation Error Pattern
For forms, handle validation errors separately:
```swift
private func submit() async {
let validations = validations
do {
try await operations.submit(data: viewModel.data)
} catch let error as MyRequest.ResponseError {
if !error.validationResults.isEmpty {
validations.replace(with: error.validationResults)
} else {
self.error = error
}
} catch {
self.error = error
}
toggleRepaint()
}
```
### Async Task Pattern
```swift
var body: some View {
VStack {
if isLoading {
ProgressView()
} else {
contentView
}
}
.task(errorBinding: $error) {
try await loadData()
}
}
private func loadData() async throws {
isLoading = true
try await operations.loadData()
isLoading = false
toggleRepaint()
}
```
### Conditional Rendering Pattern
Use ViewModel state for conditionals:
```swift
var body: some View {
VStack {
if viewModel.isEmpty {
Text(viewModel.emptyStateMessage)
} else {
ForEach(viewModel.items) { item in
ItemRow(item: item)
}
}
}
}
```
### Computed View Components Pattern
Extract reusable view fragments as computed properties:
```swift
private var headerView: some View {
HStack {
Text(viewModel.title)
Spacer()
Image(systemName: viewModel.iconName)
}
}
var body: some View {
VStack {
headerView
contentView
}
}
```
### Result/Error Handling Pattern
When a view needs to render multiple possible ViewModels (success, various error types), use an enum wrapper:
**The Wrapper ViewModel:**
```swift
@ViewModel
public struct TaskResultViewModel {
public enum Result {
case success(TaskViewModel)
case notFound(NotFoundViewModel)
case validationError(ValidationErrorViewModel)
case permissionDenied(PermissionDeniedViewModel)
}
public let result: Result
public var vmId: ViewModelId = .init(type: Self.self)
public init(result: Result) {
self.result = result
}
}
```
**The View:**
```swift
public struct TaskResultView: ViewModelView {
private let viewModel: TaskResultViewModel
public var body: some View {
switch viewModel.result {
case .success(let vm):
TaskView(viewModel: vm)
case .notFound(let vm):
NotFoundView(viewModel: vm)
case .validationError(let vm):
ValidationErrorView(viewModel: vm)
case .permissionDenied(let vm):
PermissionDeniedView(viewModel: vm)
}
}
public init(viewModel: TaskResultViewModel) {
self.viewModel = viewModel
}
}
```
**Key principles:**
- Each error scenario has its own ViewModel type
- The wrapper enum associates specific ViewModels with each case
- The view switches on the enum and renders the appropriate child view
- Maintains type safety (no `any ViewModel` existentials)
- No generic error handling - each error type is specific and meaningful
### ViewModelId Initialization - CRITICAL
**IMPORTANT:** `ViewModelId` controls SwiftUI's view identity system via the `.id(vmId)` modifier. Incorrect initialization causes SwiftUI to treat different data as the same view, breaking updates.
**β WRONG - Never use this:**
```swift
public var vmId: ViewModelId = .init() // NO! Generic identity
```
**β
MINIMUM - Use type-based identity:**
```swift
public var vmId: ViewModelId = .init(type: Self.self)
```
This ensures views of the same type get unique identities.
**β
IDEAL - Use data-based identity when available:**
```swift
public struct TaskViewModel {
public let id: ModelIdType
public var vmId: ViewModelId
public init(id: ModelIdType, /* other params */) {
self.id = id
self.vmId = .init(id: id) // Ties view identity to data identity
// ...
}
}
```
**Why this matters:**
- SwiftUI uses `.id()` modifier to determine when to recreate vs update views
- `vmId` provides this identity for ViewModelViews
- Wrong identity = views don't update when data changes
- Data-based identity (`.init(id:)`) is best because it ties view lifecycle to data lifecycle
## File Organization
```
Sources/{ViewsTarget}/
βββ {Feature}/
β βββ {Feature}View.swift # Full page β {Feature}ViewModel
β βββ {Entity}CardView.swift # Child component β {Entity}CardViewModel
β βββ {Entity}RowView.swift # Child component β {Entity}RowViewModel
β βββ {Modal}View.swift # Modal β {Modal}ViewModel
βββ Shared/
β βββ HeaderView.swift # Shared components
β βββ FooterView.swift
βββ Styles/
βββ ButtonStyles.swift # Reusable button styles
```
---
## Common Mistakes
### Computing Data in Views
```swift
// β BAD - View is transforming data
var body: some View {
Text("\(viewModel.firstName) \(viewModel.lastName)")
}
// β
GOOD - ViewModel provides shaped result
var body: some View {
Text(viewModel.fullName) // via @LocalizedCompoundString
}
```
### Forgetting to Call toggleRepaint()
```swift
// β BAD - Test infrastructure won't work
private func submit() {
operations.submit()
// Missing toggleRepaint()!
}
// β
GOOD - Always call after operations
private func submit() {
operations.submit()
toggleRepaint()
}
```
### Using Computed Properties for Display
```swift
// β BAD - View is computing
var body: some View {
if !viewModel.items.isEmpty {
Text("You have \(viewModel.items.count) items")
}
}
// β
GOOD - ViewModel provides the state
var body: some View {
if viewModel.hasItems {
Text(viewModel.itemCountMessage)
}
}
```
### Hardcoding Text
```swift
// β BAD - Not localizable
Button(action: submit) {
Text("Submit")
}
// β
GOOD - ViewModel provides localized text
Button(action: submit) {
Text(viewModel.submitButtonLabel)
}
```
### Missing Error Binding
```swift
// β BAD - Errors not handled
Button(action: submit) {
Text(viewModel.submitLabel)
}
// β
GOOD - Error binding for async actions
Button(errorBinding: $error, asyncAction: submit) {
Text(viewModel.submitLabel)
}
```
### Storing Operations in Body Instead of Init
```swift
// β BAD - Recomputed on every render
public var body: some View {
let operations = viewModel.operations
Button(action: { operations.submit() }) {
Text(viewModel.submitLabel)
}
}
// β
GOOD - Store in init
private let operations: MyOperations
public init(viewModel: MyViewModel) {
self.viewModel = viewModel
self.operations = viewModel.operations
}
```
### Mismatched Filenames
```
// β BAD - Filename doesn't match ViewModel
ViewModel: TaskListViewModel
View: TasksView.swift
// β
GOOD - Aligned names
ViewModel: TaskListViewModel
View: TaskListView.swift
```
### Incorrect ViewModelId Initialization
```swift
// β BAD - Generic identity, views won't update correctly
public var vmId: ViewModelId = .init()
// β
MINIMUM - Type-based identity
public var vmId: ViewModelId = .init(type: Self.self)
// β
IDEAL - Data-based identity (when id available)
public init(id: ModelIdType) {
self.id = id
self.vmId = .init(id: id)
}
```
### Force-Unwrapping Localizable Strings
```swift
// β BAD - Force-unwrapping to work around missing overload
import SwiftUI
Text(try! viewModel.title.localizedString) // Anti-pattern - don't do this!
Label(try! viewModel.label.localizedString, systemImage: "star")
// β
GOOD - Request the proper SwiftUI overload instead
// The correct solution is to add an init extension like this:
extension Text {
public init(_ localizable: Localizable) {
self.init(localizable.localized)
}
}
extension Label where Title == Text, Icon == Image {
public init(_ title: Localizable, systemImage: String) {
self.init(title.localized, systemImage: systemImage)
}
}
// Then views use it cleanly without force-unwraps:
Text(viewModel.title)
Label(viewModel.label, systemImage: "star")
```
**Why this matters:**
FOSMVVM provides the `Localizable` protocol for all localized strings and includes SwiftUI init overloads for common elements like `Text`. However, not every SwiftUI element has a `Localizable` overload yet.
**When you encounter a SwiftUI element that doesn't accept `Localizable` directly:**
1. **DON'T** work around it with `try! localizable.localizedString` - this bypasses the type system and spreads force-unwrap calls throughout the view code
2. **DO** request that we add the proper init overload to FOSUtilities for that SwiftUI element
3. **The pattern is simple:** Extensions that accept `Localizable` and pass `.localized` to the standard initializer
This approach keeps the codebase clean, type-safe, and eliminates force-unwraps from view code entirely.
---
## File Templates
See [reference.md](reference.md) for complete file templates.
## Naming Conventions
| Concept | Convention | Example |
|---------|------------|---------|
| View struct | `{Name}View` | `TaskListView`, `SignInView` |
| ViewModel property | `viewModel` | Always `viewModel` |
| Operations property | `operations` | Always `operations` |
| Error state | `error` | Always `error` |
| Repaint toggle | `repaintToggle` | Always `repaintToggle` |
## Common Modifiers
### FOSMVVM-Specific Modifiers
```swift
// Error alert with ViewModel strings
.alert(
errorBinding: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
)
// Async task with error handling
.task(errorBinding: $error) {
try await loadData()
}
// Async submit handler
.onAsyncSubmit {
await submit()
}
// Test data transporter (DEBUG only)
.testDataTransporter(viewModelOps: operations, repaintToggle: $repaintToggle)
// UI testing identifier
.uiTestingIdentifier("submitButton")
```
### Standard SwiftUI Modifiers
Apply standard modifiers as needed for layout, styling, etc.
## How to Use This Skill
**Invocation:**
```bash
/fosmvvm-swiftui-view-generator
```
**Prerequisites:**
- ViewModel and its structure are understood from conversation
- Optionally, specification files have been read into context
- View requirements (display-only, interactive, form, container) are clear from discussion
**Output:**
- `{ViewName}View.swift` - SwiftUI view conforming to ViewModelView protocol
**Workflow integration:**
This skill is typically used after discussing requirements or reading specification files. The skill references that context automaticallyβno file paths or Q&A needed.
## See Also
- [Architecture Patterns](../shared/architecture-patterns.md) - Mental models and patterns
- [FOSMVVMArchitecture.md](../../docs/FOSMVVMArchitecture.md) - Full FOSMVVM architecture
- [fosmvvm-viewmodel-generator](../fosmvvm-viewmodel-generator/SKILL.md) - For creating ViewModels
- [fosmvvm-ui-tests-generator](../fosmvvm-ui-tests-generator/SKILL.md) - For creating UI tests
- [reference.md](reference.md) - Complete file templates
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2026-01-23 | Initial skill for SwiftUI view generation |
---
## Referenced Files
> The following files are referenced in this skill and included for context.
### reference.md
```markdown
# FOSMVVM SwiftUI View Generator - Reference Templates
Complete file templates for generating SwiftUI ViewModelViews.
> **Conceptual context:** See [SKILL.md](SKILL.md) for when and why to use this skill.
> **Architecture context:** See [FOSMVVMArchitecture.md](../../docs/FOSMVVMArchitecture.md) for full FOSMVVM understanding.
## Placeholders
| Placeholder | Replace With | Example |
|-------------|--------------|---------|
| `{ViewName}` | View name (without "View" suffix) | `TaskList`, `SignIn` |
| `{ViewModel}` | Full ViewModel type name | `TaskListViewModel` |
| `{Operations}` | Full ViewModelOperations type name | `TaskListViewModelOperations` |
| `{ViewsTarget}` | SwiftUI views SPM target | `MyAppViews` |
| `{ViewModelsTarget}` | ViewModels SPM target | `MyAppViewModels` |
| `{Feature}` | Feature/module grouping | `Tasks`, `Auth` |
---
# Template 1: Display-Only View (No Operations)
**For views that just render data** - No user interactions, no business logic.
**Location:** `Sources/{ViewsTarget}/{Feature}/{ViewName}View.swift`
```swift
// {ViewName}View.swift
//
// Copyright (c) 2026 Your Organization. All rights reserved.
// License: Your License
import FOSMVVM
import Foundation
import SwiftUI
import {ViewModelsTarget}
/// Displays {description}
public struct {ViewName}View: ViewModelView {
private let viewModel: {ViewModel}
public var body: some View {
HStack(alignment: .top) {
Image(systemName: viewModel.iconName)
.resizable()
.frame(width: 50, height: 50)
Spacer()
VStack(alignment: .leading) {
Text(viewModel.title)
.font(.headline)
LabeledContent {
Text(viewModel.statusText)
.bold()
} label: {
Text(viewModel.statusLabel)
}
if let subtitle = viewModel.subtitle {
Text(subtitle)
.font(.caption)
}
}
}
.padding()
.background(Color.secondary.opacity(0.1))
.cornerRadius(8)
}
public init(viewModel: {ViewModel}) {
self.viewModel = viewModel
}
}
#if DEBUG
#Preview {
{ViewName}View.previewHost(
bundle: MyAppResourceAccess.localizationBundle
)
}
#Preview("With Data") {
{ViewName}View.previewHost(
bundle: MyAppResourceAccess.localizationBundle,
viewModel: .stub(
title: "Sample Title",
statusText: "Active"
)
)
}
#endif
```
---
# Template 2: Simple Interactive View
**For views with basic user interactions** - Buttons that trigger operations.
**Location:** `Sources/{ViewsTarget}/{Feature}/{ViewName}View.swift`
```swift
// {ViewName}View.swift
//
// Copyright (c) 2026 Your Organization. All rights reserved.
// License: Your License
import FOSMVVM
import Foundation
import SwiftUI
import {ViewModelsTarget}
/// Interactive view for {description}
public struct {ViewName}View: ViewModelView {
@State private var error: Error?
#if DEBUG
@State private var repaintToggle = false
#endif
private let viewModel: {ViewModel}
private let operations: {Operations}
public var body: some View {
VStack {
Text(viewModel.title)
.font(.title)
Text(viewModel.description)
.font(.body)
Spacer()
HStack {
Button(role: .cancel, action: cancel) {
Text(viewModel.cancelButtonLabel)
.foregroundStyle(.red)
}
Spacer()
Button(action: performAction) {
Text(viewModel.actionButtonLabel)
.fontWeight(.semibold)
}
.buttonStyle(.borderedProminent)
}
}
.padding()
.alert(
errorBinding: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
)
#if DEBUG
.testDataTransporter(viewModelOps: operations, repaintToggle: $repaintToggle)
#endif
}
public init(viewModel: {ViewModel}) {
self.viewModel = viewModel
self.operations = viewModel.operations
}
}
private extension {ViewName}View {
func performAction() {
operations.performAction()
toggleRepaint()
}
func cancel() {
operations.cancel()
toggleRepaint()
}
func toggleRepaint() {
#if DEBUG
repaintToggle.toggle()
#endif
}
}
#if DEBUG
#Preview {
{ViewName}View.previewHost(
bundle: MyAppResourceAccess.localizationBundle
)
}
#endif
```
---
# Template 3: Form View with Validation
**For views with validated form inputs** - Uses FormFieldView and Validations.
**Location:** `Sources/{ViewsTarget}/{Feature}/{ViewName}View.swift`
```swift
// {ViewName}View.swift
//
// Copyright (c) 2026 Your Organization. All rights reserved.
// License: Your License
import FOSMVVM
import Foundation
import SwiftUI
import {ViewModelsTarget}
/// Form for {description}
public struct {ViewName}View: ViewModelView {
@Environment(Validations.self) private var validations
@Environment(MVVMEnvironment.self) private var mvvmEnv
@Environment(\.focusState) private var focusField
@State private var error: Error?
@State private var submitSuccess: Bool = false
#if DEBUG
@State private var repaintToggle = false
#endif
private let viewModel: {ViewModel}
private let operations: {Operations}
public var body: some View {
Form {
FormFieldView(
fieldModel: viewModel.$email,
focusField: focusField,
fieldValidator: viewModel.validateEmail,
validations: validations
)
FormFieldView(
fieldModel: viewModel.$password,
focusField: focusField,
fieldValidator: viewModel.validatePassword,
validations: validations
)
FormFieldView(
fieldModel: viewModel.$name,
focusField: focusField,
fieldValidator: viewModel.validateName,
validations: validations
)
Button(errorBinding: $error, asyncAction: submit) {
Text(viewModel.submitButtonLabel)
}
.disabled(validations.hasError)
}
.onAsyncSubmit {
await submit()
}
.alert(
errorBinding: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
)
.sheet(isPresented: $submitSuccess) {
VStack {
Text(viewModel.successMessage)
Button(viewModel.closeButtonLabel) {
submitSuccess = false
}
}
.padding()
}
#if os(macOS)
.padding([.leading, .trailing])
#endif
#if DEBUG
.testDataTransporter(viewModelOps: operations, repaintToggle: $repaintToggle)
#endif
}
public init(viewModel: {ViewModel}) {
self.viewModel = viewModel
self.operations = viewModel.operations
}
}
private extension {ViewName}View {
func submit() async {
guard validations.status?.hasError != true else {
return
}
let validations = validations
do {
let response = try await operations.submit(
email: viewModel.email,
password: viewModel.password,
name: viewModel.name,
mvvmEnv: mvvmEnv
)
submitSuccess = response?.success == true
} catch let error as SubmitRequest.ResponseError {
if !error.validationResults.isEmpty {
validations.replace(with: error.validationResults)
} else {
self.error = error
}
} catch {
self.error = error
}
toggleRepaint()
}
func toggleRepaint() {
#if DEBUG
repaintToggle.toggle()
#endif
}
}
#if DEBUG
#Preview {
{ViewName}View.previewHost(
bundle: MyAppResourceAccess.localizationBundle
)
.environment(Validations())
.frame(minWidth: 300, minHeight: 200)
}
#endif
```
---
# Template 4: View with Async Loading
**For views that load data asynchronously** - With loading states and error handling.
**Location:** `Sources/{ViewsTarget}/{Feature}/{ViewName}View.swift`
```swift
// {ViewName}View.swift
//
// Copyright (c) 2026 Your Organization. All rights reserved.
// License: Your License
import FOSMVVM
import Foundation
import SwiftUI
import {ViewModelsTarget}
/// View that loads and displays {description}
public struct {ViewName}View: ViewModelView {
@Environment(AppState.self) private var appState
@State private var items: [ItemViewModel] = []
@State private var error: Error?
@State private var isLoading = false
#if DEBUG
@State private var repaintToggle = false
#endif
private let viewModel: {ViewModel}
private let operations: {Operations}
public var body: some View {
VStack {
Text(viewModel.title)
.font(.headline)
.padding()
if isLoading {
ProgressView()
} else if items.isEmpty {
Text(viewModel.emptyStateMessage)
.foregroundStyle(.secondary)
} else {
contentView
}
Spacer()
Button(errorBinding: $error, asyncAction: refresh) {
HStack {
Image(systemName: "arrow.clockwise")
Text(viewModel.refreshButtonLabel)
}
}
.padding()
}
.task(errorBinding: $error) {
try await loadItems()
}
.alert(
errorBinding: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
)
#if DEBUG
.testDataTransporter(viewModelOps: operations, repaintToggle: $repaintToggle)
#endif
}
private var contentView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
ForEach(items) { item in
ItemRowView(item: item)
}
}
}
}
public init(viewModel: {ViewModel}) {
self.viewModel = viewModel
self.operations = viewModel.operations
}
}
private extension {ViewName}View {
func loadItems() async throws {
isLoading = true
let stream = operations.loadItems()
toggleRepaint()
for try await item in stream {
items.append(item)
}
isLoading = false
toggleRepaint()
}
@Sendable func refresh() async throws {
items.removeAll()
try await loadItems()
}
func toggleRepaint() {
#if DEBUG
repaintToggle.toggle()
#endif
}
}
#if DEBUG
#Preview {
{ViewName}View.previewHost(
bundle: MyAppResourceAccess.localizationBundle
)
.environment(AppState())
}
#endif
```
---
# Template 5: Container View with Child Bindings
**For views that compose child views** - Uses .bind() to pass data to children.
**Location:** `Sources/{ViewsTarget}/{Feature}/{ViewName}View.swift`
```swift
// {ViewName}View.swift
//
// Copyright (c) 2026 Your Organization. All rights reserved.
// License: Your License
import FOSMVVM
import Foundation
import SwiftUI
import {ViewModelsTarget}
/// Container view that manages {description}
public struct {ViewName}View: ViewModelView {
@Environment(AppState.self) private var appState
#if DEBUG
@State private var repaintToggle = false
#endif
private let viewModel: {ViewModel}
private let operations: {Operations}
public var body: some View {
VStack {
Text(viewModel.pageTitle)
.font(.title)
.padding()
switch viewModel.state {
case .idle:
Button(action: startFlow) {
HStack {
Spacer()
Text(viewModel.startButtonLabel)
Spacer()
}
}
.buttonStyle(.borderedProminent)
case .selectingItem:
ItemSelectorView.bind(appState: .init())
case .itemSelected(let itemId):
statusView
ItemDetailView.bind(
appState: .init(
itemId: itemId,
isActive: viewModel.isActive,
level: viewModel.level
)
)
if appState.item != nil, viewModel.isActive {
Button(action: proceedToNext) {
HStack {
Spacer()
Text(viewModel.nextButtonLabel)
Spacer()
}
}
.buttonStyle(.borderedProminent)
}
case .selectingDetails:
DetailSelectorView.bind(appState: .init())
case .completed(let itemId, let detailId):
statusView
ItemDetailView.bind(
appState: .init(
itemId: itemId,
isActive: viewModel.isActive,
level: viewModel.level
)
)
DetailInfoView.bind(
appState: .init(
detailId: detailId,
isEnabled: viewModel.isEnabled,
status: viewModel.status
)
)
}
Spacer()
}
.padding([.leading, .trailing])
#if DEBUG
.testDataTransporter(viewModelOps: operations, repaintToggle: $repaintToggle)
#endif
}
public init(viewModel: {ViewModel}) {
self.viewModel = viewModel
self.operations = viewModel.operations
}
}
private extension {ViewName}View {
func startFlow() {
appState.state = .selectingItem
operations.startFlow()
toggleRepaint()
}
func proceedToNext() {
guard case .itemSelected(let itemId) = viewModel.state else {
return
}
appState.state = .selectingDetails(itemId: itemId)
operations.proceedToDetails()
toggleRepaint()
}
var statusView: some View {
HStack {
Text(viewModel.statusLabel)
Image(systemName: viewModel.isActive ? "checkmark.circle.fill" : "circle")
Text(viewModel.isActive ? viewModel.activeText : viewModel.inactiveText)
}
.padding(.bottom)
}
func toggleRepaint() {
#if DEBUG
repaintToggle.toggle()
#endif
}
}
#if DEBUG
#Preview("Idle") {
{ViewName}View.previewHost(
bundle: MyAppResourceAccess.localizationBundle,
viewModel: .stub(state: .idle)
)
.environment(AppState.stub())
}
#Preview("Item Selected") {
{ViewName}View.previewHost(
bundle: MyAppResourceAccess.localizationBundle,
viewModel: .stub(
state: .itemSelected(itemId: .init()),
isActive: true,
level: .high
)
)
.environment(AppState.stub())
}
#Preview("Completed") {
{ViewName}View.previewHost(
bundle: MyAppResourceAccess.localizationBundle,
viewModel: .stub(
state: .completed(itemId: .init(), detailId: .init()),
isActive: true,
isEnabled: true
)
)
.environment(AppState.stub())
}
#endif
```
---
# Template 6: List View with Selection
**For views with selectable lists** - Manages selection state and item interaction.
**Location:** `Sources/{ViewsTarget}/{Feature}/{ViewName}View.swift`
```swift
// {ViewName}View.swift
//
// Copyright (c) 2026 Your Organization. All rights reserved.
// License: Your License
import FOSMVVM
import Foundation
import SwiftUI
import {ViewModelsTarget}
/// List view for selecting {description}
public struct {ViewName}View: ViewModelView {
@State private var items: [ItemData] = []
@State private var selectedId: ModelIdType?
@State private var isProcessing: Bool = false
@State private var error: Error?
#if DEBUG
@State private var repaintToggle = false
#endif
private let viewModel: {ViewModel}
private let operations: {Operations}
public var body: some View {
VStack {
Text(viewModel.title)
.font(.headline)
.padding(.top)
HStack {
Text(viewModel.listHeaderLabel)
Spacer()
Text(isProcessing ? viewModel.processingLabel : viewModel.scanningLabel)
}
.padding(.horizontal)
Divider()
ScrollView {
VStack(alignment: .leading) {
if orderedItems.isEmpty {
Text(viewModel.emptyStateMessage)
.foregroundStyle(.secondary)
}
ForEach(orderedItems) { item in
Button { selectItem(id: item.id) } label: {
HStack {
Text(item.displayName)
if item.id == selectedId {
Spacer()
if isProcessing {
ProgressView()
} else {
Image(systemName: "checkmark.circle.fill")
}
}
}
}
.padding(.vertical, 8)
.padding(.horizontal)
.background(
item.id == selectedId
? Color.accentColor.opacity(0.2)
: Color.clear
)
Divider()
}
}
}
Spacer()
HStack {
Button(role: .cancel, action: cancel) {
Text(viewModel.cancelButtonLabel)
.foregroundStyle(.red)
}
Spacer()
Button(errorBinding: $error, asyncAction: submit) {
Text(viewModel.submitButtonLabel)
.fontWeight(.semibold)
.frame(minWidth: 120)
}
.buttonStyle(.borderedProminent)
.disabled(selectedId == nil || isProcessing)
}
.padding()
}
.task(errorBinding: $error) { try await loadItems() }
.alert(
errorBinding: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
)
#if DEBUG
.testDataTransporter(viewModelOps: operations, repaintToggle: $repaintToggle)
#endif
}
public init(viewModel: {ViewModel}) {
self.viewModel = viewModel
self.operations = viewModel.operations
}
}
private extension {ViewName}View {
func loadItems() async throws {
let stream = operations.loadItems()
toggleRepaint()
for try await nextItem in stream {
items.append(nextItem)
}
}
func cancel() {
operations.cancel()
toggleRepaint()
}
func selectItem(id: ModelIdType) {
if selectedId == id {
selectedId = nil
} else {
selectedId = id
}
toggleRepaint()
}
@Sendable func submit() async throws {
guard
let selectedId,
let item = items.first(where: { $0.id == selectedId })
else {
return
}
operations.cancel()
isProcessing = true
try await operations.submit(item: item)
isProcessing = false
toggleRepaint()
}
var orderedItems: [ItemData] {
items.sorted(by: { $0.displayName < $1.displayName })
}
func toggleRepaint() {
#if DEBUG
repaintToggle.toggle()
#endif
}
}
#if DEBUG
#Preview {
{ViewName}View.previewHost(
bundle: MyAppResourceAccess.localizationBundle
)
}
#endif
```
---
# Quick Reference
## View Component Checklist
**All Views:**
- [ ] Conforms to `ViewModelView`
- [ ] `private let viewModel: {ViewModel}`
- [ ] `public init(viewModel:)`
- [ ] Previews with `.previewHost()`
**Interactive Views (with operations):**
- [ ] `private let operations: {Operations}`
- [ ] `@State private var repaintToggle = false` (DEBUG)
- [ ] `.testDataTransporter(viewModelOps:repaintToggle:)` (DEBUG)
- [ ] `toggleRepaint()` called after all operations
- [ ] Operations stored from `viewModel.operations` in init
**Form Views:**
- [ ] `@Environment(Validations.self) private var validations`
- [ ] `@Environment(\.focusState) private var focusField`
- [ ] `FormFieldView` for each input field
- [ ] `.disabled(validations.hasError)` on submit button
- [ ] Separate handling for validation errors vs general errors
**Container Views:**
- [ ] `@Environment(AppState.self) private var appState`
- [ ] Child views use `.bind(appState:)`
- [ ] AppState created from ViewModel data
## Common Patterns
### Error Handling
```swift
@State private var error: Error?
.alert(
errorBinding: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
)
```
### Async Actions
```swift
Button(errorBinding: $error, asyncAction: submit) {
Text(viewModel.submitLabel)
}
private func submit() async {
do {
try await operations.submit()
} catch {
self.error = error
}
toggleRepaint()
}
```
### Task on Appear
```swift
.task(errorBinding: $error) {
try await loadData()
}
private func loadData() async throws {
try await operations.loadData()
toggleRepaint()
}
```
### Conditional Rendering
```swift
if viewModel.isEmpty {
Text(viewModel.emptyStateMessage)
} else {
ForEach(viewModel.items) { item in
ItemRow(item: item)
}
}
```
### Child View Binding
```swift
ChildView.bind(
appState: .init(
id: viewModel.selectedId,
isActive: viewModel.isActive
)
)
```
## Preview Patterns
```swift
#if DEBUG
// Basic preview
#Preview {
MyView.previewHost(
bundle: MyAppResourceAccess.localizationBundle
)
.environment(AppState())
}
// Named preview with data
#Preview("With Items") {
MyView.previewHost(
bundle: MyAppResourceAccess.localizationBundle,
viewModel: .stub(
items: [.stub(), .stub()],
isEmpty: false
)
)
.environment(AppState())
}
// Multiple states
#Preview("Loading") {
MyView.previewHost(
bundle: MyAppResourceAccess.localizationBundle,
viewModel: .stub(isLoading: true)
)
}
#Preview("Empty") {
MyView.previewHost(
bundle: MyAppResourceAccess.localizationBundle,
viewModel: .stub(isEmpty: true)
)
}
#endif
```
---
# Checklists
## Before Creating a View:
- [ ] ViewModel exists and is understood
- [ ] Determined if operations are needed
- [ ] Identified if form with validation
- [ ] Identified if container with child views
- [ ] Reviewed ViewModel properties to understand data
## After Creating a View:
- [ ] View conforms to ViewModelView
- [ ] Init stores viewModel (and operations if needed)
- [ ] Test infrastructure added if operations present
- [ ] Previews added for different states
- [ ] Error handling in place if async operations
- [ ] Validation handling in place if form
- [ ] Child view bindings correct if container
```
### ../fosmvvm-viewmodel-generator/SKILL.md
```markdown
---
name: fosmvvm-viewmodel-generator
description: Generate FOSMVVM ViewModels for SwiftUI screens, pages, and components. Scaffolds RequestableViewModel, localization bindings, and stub factories.
homepage: https://github.com/foscomputerservices/FOSUtilities
metadata: {"clawdbot": {"emoji": "ποΈ", "os": ["darwin", "linux"]}}
---
# FOSMVVM ViewModel Generator
Generate ViewModels following FOSMVVM architecture patterns.
## Conceptual Foundation
> For full architecture context, see [FOSMVVMArchitecture.md](../../docs/FOSMVVMArchitecture.md) | [OpenClaw reference]({baseDir}/references/FOSMVVMArchitecture.md)
A **ViewModel** is the bridge in the Model-View-ViewModel architecture:
```
βββββββββββββββ βββββββββββββββββββ βββββββββββββββ
β Model β ββββΊ β ViewModel β ββββΊ β View β
β (Data) β β (The Bridge) β β (SwiftUI) β
βββββββββββββββ βββββββββββββββββββ βββββββββββββββ
```
**Key insight:** In FOSMVVM, ViewModels are:
- **Created by a Factory** (either server-side or client-side)
- **Localized during encoding** (resolves all `@LocalizedString` references)
- **Consumed by Views** which just render the localized data
---
## First Decision: Hosting Mode
**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 |
### Server-Hosted Mode
When data comes from a server:
- Factory is **hand-written** on server (`ViewModelFactory` protocol)
- Factory queries database, builds ViewModel
- Server localizes during JSON encoding
- Client receives fully localized ViewModel
**Examples:** Sign-in screen, user profile from API, dashboard with server data
### Client-Hosted Mode
When data is local to the device:
- Use `@ViewModel(options: [.clientHostedFactory])`
- Macro **auto-generates** factory from init parameters
- Client bundles YAML resources
- Client localizes during encoding
**Examples:** Settings screen, onboarding, offline-first features, **error display**
### Error Display Pattern
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:
```swift
catch 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.ResponseError`
- `CreateIdeaErrorViewModel` for `CreateIdeaRequest.ResponseError`
- `SettingsValidationErrorViewModel` for settings form errors
Don't create a generic "ToastViewModel" or "ErrorViewModel" - that's unified error architecture, which we avoid.
**Key insights:**
- No server request needed - you already caught the error
- The `LocalizableString` properties in `ResponseError` are **already localized** (server did it)
- Standard ViewModel β View encoding chain handles this correctly; already-localized strings pass through unchanged
- Client-hosted ViewModel wraps existing data; the macro generates the factory
### Hybrid Apps
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.
### Core Responsibility: Shaping Data
A ViewModel's job is **shaping data for presentation**. This happens in two places:
1. **Factory** - *what* data is needed, *how* to transform it
2. **Localization** - *how* to present it in context (including locale-aware ordering)
**The View just renders** - it should never compose, format, or reorder ViewModel properties.
### What a ViewModel Contains
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]` |
### What a ViewModel Does NOT Contain
- Database relationships (`@Parent`, `@Siblings`)
- Business logic or validation (that's in Fields protocols)
- Raw database IDs exposed to templates (use typed properties)
- Unlocalized strings that Views must look up
### Anti-Pattern: Composition in Views
```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.
## ViewModel Protocol Hierarchy
```swift
public 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 JSON
- `RetrievablePropertyNames` - Enables `@LocalizedString` binding (via `@ViewModel` macro)
- `Identifiable` - Has `vmId` for SwiftUI identity
- `Stubbable` - Has `stub()` for testing/previews
**RequestableViewModel** adds:
- Associated `Request` type for fetching from server
## Two Categories of ViewModels
### 1. Top-Level (RequestableViewModel)
Represents a full page or screen. Has:
- An associated `ViewModelRequest` type
- A `ViewModelFactory` that builds it from database
- Child ViewModels embedded within it
```swift
@ViewModel
public struct DashboardViewModel: RequestableViewModel {
public typealias Request = DashboardRequest
@LocalizedString public var pageTitle
public let cards: [CardViewModel] // Children
public var vmId: ViewModelId = .init()
}
```
### 2. Child (plain ViewModel)
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()
}
```
---
## Display vs Form ViewModels
ViewModels serve two distinct purposes:
| Purpose | ViewModel Type | Adopts Fields? |
|---------|----------------|----------------|
| **Display data** (read-only) | Display ViewModel | No |
| **Collect user input** (editable) | Form ViewModel | Yes |
### Display ViewModels
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:**
- Properties are `let` (read-only)
- No validation needed
- No FormField definitions
- Just projects Model data for display
### Form ViewModels
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:**
- Properties are `var` (editable)
- **Adopts a Fields protocol** for validation
- Gets FormField definitions from Fields
- Gets validation logic from Fields
- Gets localized error messages from Fields
### The Connection
```
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β UserFields Protocol β
β (defines editable properties + validation) β
β β
β Adopted by: β
β βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ β
β β CreateUserReq β β UserFormVM β β User (Model) β β
β β .RequestBody β β (UI form) β β (persistence) β β
β βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ β
β β
β Same validation logic everywhere! β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
```
### Quick Decision Guide
**The key question: "Is the user editing data in this ViewModel?"**
- **No** β Display ViewModel (no Fields)
- **Yes** β Form ViewModel (adopt Fields)
| 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` |
---
## When to Use This Skill
- Creating a new page or screen
- Adding a new UI component (card, row, modal, etc.)
- Displaying data from the database in a View
- Following an implementation plan that requires new ViewModels
## What This Skill Generates
### Server-Hosted: Top-Level ViewModel (4 files)
| 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 |
### Client-Hosted: Top-Level ViewModel (2 files)
| 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!*
### Child ViewModels (1-2 files, either mode)
| 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.
## Project Structure Configuration
| Placeholder | Description | Example |
|-------------|-------------|---------|
| `{ViewModelsTarget}` | Shared ViewModels SPM target | `ViewModels` |
| `{ResourcesPath}` | Localization resources | `Sources/Resources` |
| `{WebServerTarget}` | Server-side target | `WebServer`, `AppServer` |
## How to Use This Skill
**Invocation:**
/fosmvvm-viewmodel-generator
**Prerequisites:**
- View requirements understood from conversation context
- Data source determined (server/database vs local state)
- Display vs Form decision made (if user input involved, Fields protocol exists)
**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.
## Pattern Implementation
This skill references conversation context to determine ViewModel structure:
### Hosting Mode Detection
From conversation context, the skill identifies:
- **Data source** (server/database vs local state/preferences)
- Server-hosted β Hand-written factory, server-side localization
- Client-hosted β Macro-generated factory, client-side localization
### ViewModel Design
From requirements already in context:
- **View purpose** (page, modal, card, row component)
- **Data needs** (from database query, from AppState, from caught error)
- **Static UI text** (titles, labels, buttons requiring @LocalizedString)
- **Child ViewModels** (nested components)
- **Hierarchy level** (top-level RequestableViewModel vs child ViewModel)
### Property Planning
Based on View requirements:
- **Display properties** (data to render)
- **Localization requirements** (which properties use @LocalizedString)
- **Identity strategy** (singleton vmId vs instance-based vmId)
- **Form adoption** (whether ViewModel adopts Fields protocol)
### File Generation
**Server-Hosted Top-Level:**
1. ViewModel struct (with `RequestableViewModel`)
2. Request type
3. YAML localization
4. Factory implementation
**Client-Hosted Top-Level:**
1. ViewModel struct (with `clientHostedFactory` option)
2. YAML localization
**Child (either mode):**
1. ViewModel struct
2. YAML localization (if needed)
### Context Sources
Skill references information from:
- **Prior conversation**: View requirements, data sources discussed with user
- **Specification files**: If Claude has read UI specs or feature docs into context
- **Fields protocols**: From codebase or previous fosmvvm-fields-generator invocation
## Key Patterns
### The @ViewModel Macro
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 { ... }
```
### Stubbable Pattern
All ViewModels must support `stub()` for testing and SwiftUI previews:
```swift
public extension MyViewModel {
static func stub() -> Self {
.init(/* default values */)
}
}
```
### Identity: vmId
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`
### Localization
Static UI text uses `@LocalizedString`:
```swift
@LocalizedString public var pageTitle
```
With corresponding YAML:
```yaml
en:
MyViewModel:
pageTitle: "Welcome"
```
### Dates and Numbers
Never send pre-formatted strings. Use localizable types:
```swift
public let createdAt: LocalizableDate // NOT String
public let itemCount: LocalizableInt // NOT String
```
The client formats these according to user's locale and timezone.
### Enum Localization Pattern
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.
### Child ViewModels
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.
#### Nested Child Types Pattern
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:**
1. Nested types go AFTER the properties that reference them
2. Before `vmId` and the parent's init
3. Use `// MARK: - Nested Types` section marker
4. Each nested type gets its own doc comment
**Conformances for nested types:**
- `Codable` - for ViewModel encoding
- `Sendable` - for Swift 6 concurrency
- `Identifiable` - for SwiftUI ForEach if used in arrays
- `Stubbable` - for testing/previews
**Two-Tier Stubbable Pattern:**
Nested types use fully qualified names in their extensions:
```swift
public 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:**
- Tests often just need `[.stub()]` without caring about values
- Other tests need specific values: `.stub(name: "Specific Name")`
- Zero-arg ALWAYS calls parameterized version (single source of truth)
**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
// ...
}
}
```
### Codable and Computed Properties
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` property
Pre-compute only when:
- Direct array subscripts needed (`firstCard` - array indexing not documented in Leaf)
- Complex logic that's cleaner in Swift than in template
- Performance-sensitive repeated calculations
See [fosmvvm-leaf-view-generator](../fosmvvm-leaf-view-generator/SKILL.md) for Leaf template patterns.
## File Templates
See [reference.md](reference.md) for complete file templates.
## Naming Conventions
| 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` |
## See Also
- [Architecture Patterns](../shared/architecture-patterns.md) - Mental models (errors are data, type safety, etc.)
- [FOSMVVMArchitecture.md](../../docs/FOSMVVMArchitecture.md) - Full FOSMVVM architecture
- [fosmvvm-fields-generator](../fosmvvm-fields-generator/SKILL.md) - For form validation
- [fosmvvm-fluent-datamodel-generator](../fosmvvm-fluent-datamodel-generator/SKILL.md) - For Fluent persistence layer
- [fosmvvm-leaf-view-generator](../fosmvvm-leaf-view-generator/SKILL.md) - For Leaf templates that render ViewModels
- [reference.md](reference.md) - Complete file templates
## Version History
| 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. |
```
### ../fosmvvm-ui-tests-generator/SKILL.md
```markdown
---
name: fosmvvm-ui-tests-generator
description: Generate UI tests for FOSMVVM SwiftUI views using XCTest and FOSTestingUI. Covers accessibility identifiers, ViewModelOperations, and test data transport.
homepage: https://github.com/foscomputerservices/FOSUtilities
metadata: {"clawdbot": {"emoji": "π₯οΈ", "os": ["darwin"]}}
---
# FOSMVVM UI Tests Generator
Generate comprehensive UI tests for ViewModelViews in FOSMVVM applications.
## Conceptual Foundation
> For full architecture context, see [FOSMVVMArchitecture.md](../../docs/FOSMVVMArchitecture.md) | [OpenClaw reference]({baseDir}/references/FOSMVVMArchitecture.md)
UI testing in FOSMVVM follows a specific pattern that leverages:
- **FOSTestingUI** framework for test infrastructure
- **ViewModelOperations** for verifying business logic was invoked
- **Accessibility identifiers** for finding UI elements
- **Test data transporter** for passing operation stubs to the app
```
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β UI Test Architecture β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Test File (XCTest) App Under Test β
β ββββββββββββββββββββ ββββββββββββββββββββ β
β β MyViewUITests β β MyView β β
β β β β β β
β β presentView() ββββΌββββββββββββββΊβ Show view with β β
β β with stub VM β β stubbed data β β
β β β β β β
β β Interact via βββββΌββββββββββββββΊβ UI elements with β β
β β identifiers β β .uiTestingId β β
β β β β β β
β β Assert on UI β β .testDataβββββββββΌβββ β
β β state β β Transporter β β β
β β β ββββββββββββββββββββ β β
β β viewModelOps() βββΌββββββββββββββββββββββββββββββββββββββ β
β β verify calls β Stub Operations β
β ββββββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
```
## Core Components
### 1. Base Test Case Class
Every project should have a base test case that inherits from `ViewModelViewTestCase`:
```swift
class MyAppViewModelViewTestCase<VM: ViewModel, VMO: ViewModelOperations>:
ViewModelViewTestCase<VM, VMO>, @unchecked Sendable {
@MainActor func presentView(
configuration: TestConfiguration,
viewModel: VM = .stub(),
timeout: TimeInterval = 3
) throws -> XCUIApplication {
try presentView(
testConfiguration: configuration.toJSON(),
viewModel: viewModel,
timeout: timeout
)
}
override func setUp() async throws {
try await super.setUp(
bundle: Bundle.main,
resourceDirectoryName: "",
appBundleIdentifier: "com.example.MyApp"
)
continueAfterFailure = false
}
}
```
**Key points:**
- Generic over `ViewModel` and `ViewModelOperations`
- Wraps FOSTestingUI's `presentView()` with project-specific configuration
- Sets up bundle and app bundle identifier
- `continueAfterFailure = false` stops tests immediately on failure
### 2. Individual UI Test Files
Each ViewModelView gets a corresponding UI test file.
**For views WITH operations:**
```swift
final class MyViewUITests: MyAppViewModelViewTestCase<MyViewModel, MyViewOps> {
// UI Tests - verify UI state
func testButtonEnabled() async throws {
let app = try presentView(viewModel: .stub(enabled: true))
XCTAssertTrue(app.myButton.isEnabled)
}
// Operation Tests - verify operations were called
func testButtonTap() async throws {
let app = try presentView(configuration: .requireSomeState())
app.myButton.tap()
let stubOps = try viewModelOperations()
XCTAssertTrue(stubOps.myOperationCalled)
}
}
private extension XCUIApplication {
var myButton: XCUIElement {
buttons.element(matching: .button, identifier: "myButtonIdentifier")
}
}
```
**For views WITHOUT operations** (display-only):
Use an empty stub operations protocol:
```swift
// In your test file
protocol MyViewStubOps: ViewModelOperations {}
struct MyViewStubOpsImpl: MyViewStubOps {}
final class MyViewUITests: MyAppViewModelViewTestCase<MyViewModel, MyViewStubOpsImpl> {
// UI Tests only - no operation verification
func testDisplaysCorrectly() async throws {
let app = try presentView(viewModel: .stub(title: "Test"))
XCTAssertTrue(app.titleLabel.exists)
}
}
```
**When to use each:**
- **With operations**: Interactive views that perform actions (forms, buttons that call APIs, etc.)
- **Without operations**: Display-only views (cards, detail views, static content)
### 3. XCUIElement Helper Extensions
Common helpers for interacting with UI elements:
```swift
extension XCUIElement {
var text: String? {
value as? String
}
func typeTextAndWait(_ string: String, timeout: TimeInterval = 2) {
typeText(string)
_ = wait(for: \.text, toEqual: string, timeout: timeout)
}
func tapMenu() {
if isHittable {
tap()
} else {
coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
}
}
```
### 4. View Requirements
**For views WITH operations:**
```swift
public struct MyView: ViewModelView {
#if DEBUG
@State private var repaintToggle = false
#endif
private let viewModel: MyViewModel
private let operations: MyViewModelOperations
public var body: some View {
Button(action: doSomething) {
Text(viewModel.buttonLabel)
}
.uiTestingIdentifier("myButtonIdentifier")
#if DEBUG
.testDataTransporter(viewModelOps: operations, repaintToggle: $repaintToggle)
#endif
}
public init(viewModel: MyViewModel) {
self.viewModel = viewModel
self.operations = viewModel.operations
}
private func doSomething() {
operations.doSomething()
toggleRepaint()
}
private func toggleRepaint() {
#if DEBUG
repaintToggle.toggle()
#endif
}
}
```
**For views WITHOUT operations** (display-only):
```swift
public struct MyView: ViewModelView {
private let viewModel: MyViewModel
public var body: some View {
VStack {
Text(viewModel.title)
Text(viewModel.description)
}
.uiTestingIdentifier("mainContent")
}
public init(viewModel: MyViewModel) {
self.viewModel = viewModel
}
}
```
**Critical patterns (for views WITH operations):**
- `@State private var repaintToggle = false` for triggering test data transport
- `.testDataTransporter(viewModelOps:repaintToggle:)` modifier in DEBUG
- `toggleRepaint()` called after every operation invocation
- `operations` stored as property from `viewModel.operations`
**Display-only views:**
- No `repaintToggle` needed
- No `.testDataTransporter()` modifier needed
- Just add `.uiTestingIdentifier()` to elements you want to test
## ViewModelOperations: Optional
Not all views need ViewModelOperations:
**Views that NEED operations:**
- Forms with submit/cancel actions
- Views that call business logic or APIs
- Interactive views that trigger app state changes
- Views with user-initiated async operations
**Views that DON'T NEED operations:**
- Display-only cards or detail views
- Static content views
- Pure navigation containers
- Server-hosted views that just render data
**For views without operations:**
Create an empty operations file alongside your ViewModel:
```swift
// MyDisplayViewModelOperations.swift
import FOSMVVM
import Foundation
public protocol MyDisplayViewModelOperations: ViewModelOperations {}
#if canImport(SwiftUI)
public final class MyDisplayViewStubOps: MyDisplayViewModelOperations, @unchecked Sendable {
public init() {}
}
#endif
```
Then use it in tests:
```swift
final class MyDisplayViewUITests: MyAppViewModelViewTestCase<
MyDisplayViewModel,
MyDisplayViewStubOps
> {
// Only test UI state, no operation verification
}
```
The view itself doesn't need:
- `repaintToggle` state
- `.testDataTransporter()` modifier
- `operations` property
- `toggleRepaint()` function
Just add `.uiTestingIdentifier()` to elements you want to verify.
## Test Categories
### UI State Tests
Verify that the UI displays correctly based on ViewModel state:
```swift
func testButtonDisabledWhenNotReady() async throws {
let app = try presentView(viewModel: .stub(ready: false))
XCTAssertFalse(app.submitButton.isEnabled)
}
func testButtonEnabledWhenReady() async throws {
let app = try presentView(viewModel: .stub(ready: true))
XCTAssertTrue(app.submitButton.isEnabled)
}
```
### Operation Tests
Verify that user interactions invoke the correct operations:
```swift
func testSubmitButtonInvokesOperation() async throws {
let app = try presentView(configuration: .requireAuth())
app.submitButton.tap()
let stubOps = try viewModelOperations()
XCTAssertTrue(stubOps.submitCalled)
XCTAssertFalse(stubOps.cancelCalled)
}
```
### Navigation Tests
Verify navigation flows work correctly:
```swift
func testNavigationToDetailView() async throws {
let app = try presentView()
app.itemRow.tap()
XCTAssertTrue(app.detailView.exists)
}
```
## When to Use This Skill
- Adding UI tests for a new ViewModelView
- Setting up UI test infrastructure for a FOSMVVM project
- Following an implementation plan that requires test coverage
- Validating user interaction flows
## What This Skill Generates
### Initial Setup (once per project)
| File | Location | Purpose |
|------|----------|---------|
| `{ProjectName}ViewModelViewTestCase.swift` | `Tests/UITests/Support/` | Base test case for all UI tests |
| `XCUIElement.swift` | `Tests/UITests/Support/` | Helper extensions for XCUIElement |
### Per ViewModelView
| File | Location | Purpose |
|------|----------|---------|
| `{ViewName}ViewModelOperations.swift` | `Sources/{ViewModelsTarget}/{Feature}/` | Operations protocol and stub (if view has interactions) |
| `{ViewName}UITests.swift` | `Tests/UITests/Views/{Feature}/` | UI tests for the view |
**Note:** Views without user interactions use an empty operations file with just the protocol and minimal stub.
## Project Structure Configuration
| Placeholder | Description | Example |
|-------------|-------------|---------|
| `{ProjectName}` | Your project/app name | `MyApp`, `TaskManager` |
| `{ViewName}` | The ViewModelView name (without "View" suffix) | `TaskList`, `Dashboard` |
| `{Feature}` | Feature/module grouping | `Tasks`, `Settings` |
## How to Use This Skill
**Invocation:**
/fosmvvm-ui-tests-generator
**Prerequisites:**
- View and ViewModel structure understood from conversation context
- ViewModelOperations type identified (or confirmed as display-only)
- Interactive elements and user flows discussed
**Workflow integration:**
This skill is typically used after implementing ViewModelViews. The skill references conversation context automaticallyβno file paths or Q&A needed. Often follows fosmvvm-swiftui-view-generator or fosmvvm-react-view-generator.
## Pattern Implementation
This skill references conversation context to determine test structure:
### Test Type Detection
From conversation context, the skill identifies:
- **First test vs additional test** (whether base test infrastructure exists)
- **ViewModel type** (from prior discussion or View implementation)
- **ViewModelOperations type** (from View implementation or context)
- **Interactive vs display-only** (whether operations need verification)
### View Analysis
From requirements already in context:
- **Interactive elements** (buttons, fields, controls requiring test coverage)
- **User flows** (navigation paths, form submission, drag-and-drop)
- **State variations** (enabled/disabled, visible/hidden, error states)
- **Operation triggers** (which UI actions invoke which operations)
### Infrastructure Planning
Based on project state:
- **Base test case** (create if first test, reuse if exists)
- **XCUIElement extensions** (helper methods for common interactions)
- **App bundle identifier** (for launching test host)
### Test File Generation
For the specific view:
1. Test class inheriting from base test case
2. UI state tests (verify display based on ViewModel)
3. Operation tests (verify user interactions invoke operations)
4. XCUIApplication extension with element accessors
### View Requirements
Ensure test identifiers and data transport:
1. `.uiTestingIdentifier()` on all interactive elements
2. `@State private var repaintToggle` (if has operations)
3. `.testDataTransporter()` modifier (if has operations)
4. `toggleRepaint()` calls after operations (if has operations)
### Context Sources
Skill references information from:
- **Prior conversation**: View requirements, user flows discussed
- **View implementation**: If Claude has read View code into context
- **ViewModelOperations**: From codebase or discussion
## Key Patterns
### Test Configuration Pattern
Use `TestConfiguration` for tests that need specific app state:
```swift
func testWithSpecificState() async throws {
let app = try presentView(
configuration: .requireAuth(userId: "123")
)
// Test with authenticated state
}
```
### Element Accessor Pattern
Define element accessors in a private extension:
```swift
private extension XCUIApplication {
var submitButton: XCUIElement {
buttons.element(matching: .button, identifier: "submitButton")
}
var cancelButton: XCUIElement {
buttons.element(matching: .button, identifier: "cancelButton")
}
var firstItem: XCUIElement {
buttons.element(matching: .button, identifier: "itemButton").firstMatch
}
}
```
### Operation Verification Pattern
After user interactions, verify operations were called:
```swift
func testDecrementButton() async throws {
let app = try presentView(configuration: .requireDevice())
app.decrementButton.tap()
let stubOps = try viewModelOperations()
XCTAssertTrue(stubOps.decrementCalled)
XCTAssertFalse(stubOps.incrementCalled)
}
```
### Orientation Setup Pattern
Set device orientation in `setUp()` if needed:
```swift
override func setUp() async throws {
try await super.setUp()
#if os(iOS)
XCUIDevice.shared.orientation = .portrait
#endif
}
```
## View Testing Checklist
**All views:**
- [ ] `.uiTestingIdentifier()` on all elements you want to test
**Views WITH operations (interactive views):**
- [ ] `@State private var repaintToggle = false` property
- [ ] `.testDataTransporter(viewModelOps:repaintToggle:)` modifier
- [ ] `toggleRepaint()` helper function
- [ ] `toggleRepaint()` called after every operation invocation
- [ ] `operations` stored from `viewModel.operations` in init
**Views WITHOUT operations (display-only):**
- [ ] No `repaintToggle` needed
- [ ] No `.testDataTransporter()` needed
- [ ] No `operations` property needed
- [ ] `operations` stored from `viewModel.operations` in init
## Common Test Patterns
### Testing Async Operations
```swift
func testAsyncOperation() async throws {
let app = try presentView()
app.loadButton.tap()
// Wait for UI to update
_ = app.waitForExistence(timeout: 3)
let stubOps = try viewModelOperations()
XCTAssertTrue(stubOps.loadCalled)
}
```
### Testing Form Input
```swift
func testFormInput() async throws {
let app = try presentView()
let emailField = app.emailTextField
emailField.tap()
emailField.typeTextAndWait("[email protected]")
app.submitButton.tap()
let stubOps = try viewModelOperations()
XCTAssertTrue(stubOps.submitCalled)
}
```
### Testing Error States
```swift
func testErrorDisplay() async throws {
let app = try presentView(viewModel: .stub(hasError: true))
XCTAssertTrue(app.errorAlert.exists)
XCTAssertEqual(app.errorMessage.text, "An error occurred")
}
```
## File Templates
See [reference.md](reference.md) for complete file templates.
## Naming Conventions
| Concept | Convention | Example |
|---------|------------|---------|
| Base test case | `{ProjectName}ViewModelViewTestCase` | `MyAppViewModelViewTestCase` |
| UI test file | `{ViewName}UITests` | `TaskListViewUITests` |
| Test method (UI state) | `test{Condition}` | `testButtonEnabled` |
| Test method (operation) | `test{Action}` | `testSubmitButton` |
| Element accessor | `{elementName}` | `submitButton`, `emailTextField` |
| UI testing identifier | `{elementName}Identifier` or `{elementName}` | `"submitButton"`, `"emailTextField"` |
## See Also
- [Architecture Patterns](../shared/architecture-patterns.md) - Mental models and patterns
- [FOSMVVMArchitecture.md](../../docs/FOSMVVMArchitecture.md) - Full FOSMVVM architecture
- [fosmvvm-viewmodel-generator](../fosmvvm-viewmodel-generator/SKILL.md) - For creating ViewModels
- [fosmvvm-swiftui-app-setup](../fosmvvm-swiftui-app-setup/SKILL.md) - For app test host setup
- [reference.md](reference.md) - Complete file templates
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2026-01-23 | Initial skill for UI tests |
| 1.1 | 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. |
```
---
## Skill Companion Files
> Additional files collected from the skill directory layout.
### _meta.json
```json
{
"owner": "foscomputerservices",
"slug": "fosmvvm-swiftui-view-generator",
"displayName": "FOSMVVM SwiftUI View Generator",
"latest": {
"version": "2.0.6",
"publishedAt": 1771143439324,
"commit": "https://github.com/openclaw/skills/commit/25f57a8278a5b61e3f9b869145368b7118c30415"
},
"history": []
}
```