Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "fosmvvm-generators",
"description": "FOSMVVM architecture generators for ViewModels, Fields, DataModels, ServerRequests, Leaf Views, and ViewModel Tests",
"version": "2.26.0",
"version": "2.27.0",
"author": {
"name": "FOS Computer Services"
},
Expand Down
1 change: 1 addition & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ Before hand-writing a helper, check whether it already exists — the catalog in
- Credential rejection / typed 401 recovery → `FOSMVVM.md § Protocols`
- Form fields and input validation → `FOSMVVM.md § Forms`, `§ Validation`
- SwiftUI binding/app setup, property versioning, deployment URLs → `FOSMVVM.md § SwiftUI Support`, `§ Versioning`
- Async Button actions (error routing, re-entry, cancel), localized error alerts → `FOSMVVM.md § SwiftUI Support`, `§ Protocols`
- Vapor boot/Leaf, routes, Fluent factories, versioned middleware → `FOSMVVMVapor.md § Extensions`, `§ Vapor Support`, `§ Protocols`, `§ Middleware`
- Live ViewModel refresh (server push), incl. nudging live clients from non-Fluent/hybrid sources → `FOSMVVMVapor.md § Live Invalidation`
- Testing ViewModels / UI / ServerRequests → `FOSTesting.md § FOSTesting`, `§ FOSTestingUI`, `§ FOSTestingVapor`
Expand Down
29 changes: 29 additions & 0 deletions .claude/skills/fosmvvm-serverrequest-generator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,35 @@ encapsulation in `CLAUDE.md`). The typed error IS the contract; the moment a
client sniffs a status, the error vocabulary you declared stops being the
only door.

**User-presentable errors conform to `LocalizableError`.** When the error will
be shown to the user (the common case — the client's `alert(error:)` presents
the screen's error binding), compose the `ResponseError` like a ViewModel:
`@LocalizableError` macro, `@LocalizedString`/`@LocalizedSubs` message
property, exposed as `localizedMessage`. `ErrorMiddleware` localizes the
message as it encodes the throw, so the client presents it with no
localization store of its own — the same encode-time localization every
ViewModel property gets (SRP: the error carries its meaning; presentation
never invents copy for it):

```swift
@LocalizableError
public struct ResponseError: ServerRequestError {
public let maximum: Int

@LocalizedSubs(substitutions: \.subs) public var errorMessage
public var localizedMessage: any Localizable { errorMessage }

private var subs: [String: any Localizable] {
["maximum": LocalizableInt(value: maximum)]
}

public init(maximum: Int) { self.maximum = maximum }
}
```

The YAML rides the request's existing localization file — keys derive from
the error's type + property names, exactly as ViewModel properties do.

---

## Request Protocol Selection
Expand Down
79 changes: 33 additions & 46 deletions .claude/skills/fosmvvm-swiftui-view-generator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ public struct MyView: ViewModelView {

**Why `toggleRepaint()` exists.** Client-hosted ops mutate `@Observable` storage that test harnesses (and occasionally SwiftUI itself) don't always re-observe in time for the next assertion. Toggling a `@State` flag forces a deterministic re-render at the View boundary, so UI tests see the post-op state instead of the pre-op state. In production builds the toggle is compiled out — it costs nothing at runtime.

**Async vs sync op shape.** Server-backed ops are typically async (`try await operations.performAction()`) and pair with `Button(errorBinding:asyncAction:)` / `.task(errorBinding:)` / `.onAsyncSubmit`. Client-hosted scalar-mutation ops are typically sync — the live op writes a property on `@Observable` storage and returns. Don't add `async` to an op method that doesn't need it; don't omit `async` on one that calls a `ServerRequest`.
**Async vs sync op shape.** Server-backed ops are typically async (`try await operations.performAction()`) and pair with the async `Button` forms — `Button(error:action:)` and its `Localizable`-titled twins — which deposit a thrown error into the screen's `error:` binding (add `activity:` for re-entry refusal, `cancelTitle:` for tap-to-cancel; see the FOSMVVM DocC article *Async Actions and Error Presentation*). For view-lifetime loads, catch into the binding by hand inside `.task { }` — an error-routing `.task` twin is queued in FOSUtilities but not yet shipped. Client-hosted scalar-mutation ops are typically sync — the live op writes a property on `@Observable` storage and returns. Don't add `async` to an op method that doesn't need it; don't omit `async` on one that calls a `ServerRequest`.

The example above shows a **server-backed** op — `operations.performAction()` dispatches a `ServerRequest`. For **client-hosted** ops (those that mutate local `@Observable` storage), the call site shape is different — the View must inject storage from the environment and hand it to the op explicitly. See below.

Expand Down Expand Up @@ -338,16 +338,14 @@ public struct MyFormView: ViewModelView {
validations: validations
)

Button(errorBinding: $error, asyncAction: submit) {
Text(viewModel.submitButtonLabel)
}
.disabled(validations.hasError)
Button(viewModel.submitButtonLabel, error: $error, action: submit)
.disabled(validations.hasError)
}
.onAsyncSubmit {
await submit()
.onSubmit {
Task { do { try await submit() } catch { self.error = error } }
}
.alert(
errorBinding: $error,
error: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
Expand All @@ -359,7 +357,7 @@ public struct MyFormView: ViewModelView {
**Form patterns:**
- `@Environment(Validations.self)` for validation state
- `FormFieldView` for each input field
- `Button(errorBinding:asyncAction:)` for async actions
- `Button(error:action:)` (and its `Localizable`-titled twins) for async actions
- `.disabled(validations.hasError)` on submit button
- Separate handling for validation errors vs general errors

Expand Down Expand Up @@ -454,7 +452,7 @@ public struct ActionView: ViewModelView {
}
}
.alert(
errorBinding: $error,
error: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
Expand Down Expand Up @@ -595,57 +593,47 @@ Skill references information from:

### Error Handling Pattern

The async `Button` forms own the catch: a thrown error lands in the `error:` binding (cleared on each launch), and one `alert(error:)` per screen presents it. The action closure is `@Sendable () async throws` — a thin dispatch into ops, never a hand-rolled `do/catch`:

```swift
@State private var error: Error?

var body: some View {
VStack {
Button(errorBinding: $error, asyncAction: submit) {
Text(viewModel.submitLabel)
Button(viewModel.submitLabel, error: $error) {
try await operations.submit()
}
}
.alert(
errorBinding: $error,
error: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
)
}

private func submit() async {
do {
try await operations.submit()
} catch {
self.error = error
}
toggleRepaint()
}
```

Add `activity: $activity` (an `@State AsyncButtonActivity`) to refuse re-entry while a run is in flight and to drive `disabled(activity.isRunning)`; add `cancelTitle:` to make the button tap-to-cancel. See the FOSMVVM DocC article *Async Actions and Error Presentation*.

### Validation Error Pattern

For forms, handle validation errors separately:
For forms, intercept validation failures inside the action and let everything else flow to the `error:` binding:

```swift
private func submit() async {
let validations = validations
Button(viewModel.submitLabel, error: $error) {
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
} catch let responseError as MyRequest.ResponseError
where !responseError.validationResults.isEmpty {
await MainActor.run { validations.replace(with: responseError.validationResults) }
}
toggleRepaint()
}
```

### Async Task Pattern

For view-lifetime loads, catch into the binding by hand (an error-routing `.task` twin is queued in FOSUtilities but not yet shipped):

```swift
var body: some View {
VStack {
Expand All @@ -655,8 +643,8 @@ var body: some View {
contentView
}
}
.task(errorBinding: $error) {
try await loadData()
.task {
do { try await loadData() } catch { self.error = error }
}
}

Expand Down Expand Up @@ -888,9 +876,7 @@ Button(action: submit) {
}

// ✅ GOOD - Error binding for async actions
Button(errorBinding: $error, asyncAction: submit) {
Text(viewModel.submitLabel)
}
Button(viewModel.submitLabel, error: $error, action: submit)
```

### Storing Operations in Body Instead of Init
Expand Down Expand Up @@ -1004,20 +990,21 @@ See [reference.md](reference.md) for complete file templates.
```swift
// Error alert with ViewModel strings
.alert(
errorBinding: $error,
error: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
)

// Async task with error handling
.task(errorBinding: $error) {
try await loadData()
// Async task — catch into the binding by hand (an error-routing
// .task twin is queued in FOSUtilities but not yet shipped)
.task {
do { try await loadData() } catch { self.error = error }
}

// Async submit handler
.onAsyncSubmit {
await submit()
// Keyboard submit routing into the same binding
.onSubmit {
Task { do { try await submit() } catch { self.error = error } }
}

// Test data transporter (DEBUG only)
Expand Down
49 changes: 23 additions & 26 deletions .claude/skills/fosmvvm-swiftui-view-generator/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ public struct {ViewName}View: ViewModelView {
}
.padding()
.alert(
errorBinding: $error,
error: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
Expand Down Expand Up @@ -367,16 +367,16 @@ public struct {ViewName}View: ViewModelView {
validations: validations
)

Button(errorBinding: $error, asyncAction: submit) {
Button(error: $error, action: submit) {
Text(viewModel.submitButtonLabel)
}
.disabled(validations.hasError)
}
.onAsyncSubmit {
await submit()
.onSubmit {
Task { await submit() }
}
.alert(
errorBinding: $error,
error: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
Expand Down Expand Up @@ -501,19 +501,19 @@ public struct {ViewName}View: ViewModelView {

Spacer()

Button(errorBinding: $error, asyncAction: refresh) {
Button(error: $error, action: refresh) {
HStack {
Image(systemName: "arrow.clockwise")
Text(viewModel.refreshButtonLabel)
}
}
.padding()
}
.task(errorBinding: $error) {
try await loadItems()
.task {
do { try await loadItems() } catch { self.error = error }
}
.alert(
errorBinding: $error,
error: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
Expand Down Expand Up @@ -844,7 +844,7 @@ public struct {ViewName}View: ViewModelView {

Spacer()

Button(errorBinding: $error, asyncAction: submit) {
Button(error: $error, action: submit) {
Text(viewModel.submitButtonLabel)
.fontWeight(.semibold)
.frame(minWidth: 120)
Expand All @@ -854,9 +854,9 @@ public struct {ViewName}View: ViewModelView {
}
.padding()
}
.task(errorBinding: $error) { try await loadItems() }
.task { do { try await loadItems() } catch { self.error = error } }
.alert(
errorBinding: $error,
error: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
Expand Down Expand Up @@ -972,7 +972,7 @@ private extension {ViewName}View {
@State private var error: Error?

.alert(
errorBinding: $error,
error: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
Expand All @@ -981,26 +981,23 @@ private extension {ViewName}View {

### Async Actions

```swift
Button(errorBinding: $error, asyncAction: submit) {
Text(viewModel.submitLabel)
}
The async `Button` forms own the catch — the action is a thin throwing dispatch, and the thrown error lands in the `error:` binding:

private func submit() async {
do {
try await operations.submit()
} catch {
self.error = error
}
toggleRepaint()
```swift
Button(viewModel.submitLabel, error: $error) {
try await operations.submit()
}
```

Add `activity: $activity` (an `@State AsyncButtonActivity`) for re-entry refusal and running-state display; add `cancelTitle:` for tap-to-cancel. See the FOSMVVM DocC article *Async Actions and Error Presentation*.

### Task on Appear

Catch into the binding by hand — an error-routing `.task` twin is queued in FOSUtilities but not yet shipped:

```swift
.task(errorBinding: $error) {
try await loadData()
.task {
do { try await loadData() } catch { self.error = error }
}

private func loadData() async throws {
Expand Down
16 changes: 8 additions & 8 deletions .claude/skills/fosmvvm-ui-tests-generator/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,11 +561,11 @@ public struct {ViewName}View: ViewModelView {
contentView
}
}
.task(errorBinding: $error) {
try await loadData()
.task {
do { try await loadData() } catch { self.error = error }
}
.alert(
errorBinding: $error,
error: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
Expand All @@ -579,7 +579,7 @@ public struct {ViewName}View: ViewModelView {
VStack {
Text(viewModel.title)

Button(errorBinding: $error, asyncAction: submit) {
Button(error: $error, action: submit) {
Text(viewModel.submitButtonLabel)
}
.uiTestingIdentifier("submitButton")
Expand Down Expand Up @@ -691,19 +691,19 @@ public struct {ViewName}View: ViewModelView {

Spacer()

Button(errorBinding: $error, asyncAction: submit) {
Button(error: $error, action: submit) {
Text(viewModel.submitButtonLabel)
}
.buttonStyle(PrimaryButtonStyle())
.uiTestingIdentifier("submitButton")
.disabled(selectedId == nil)
}
}
.task(errorBinding: $error) {
try await loadItems()
.task {
do { try await loadItems() } catch { self.error = error }
}
.alert(
errorBinding: $error,
error: $error,
title: viewModel.errorTitle,
message: viewModel.errorMessage,
dismissButtonLabel: viewModel.dismissButtonLabel
Expand Down
Loading
Loading