swiftship/internal/skills/data/ui/feedback-states/SKILL.md
Feedback state patterns: loading indicators, error handling UI, success confirmations, disabled states, skeleton views. Use when adding loading spinners, error alerts, success feedback, or managing empty/error/loading view states. Triggers: ProgressView, loading, error, alert, empty state, skeleton, disabled, feedback.
npx skillsauth add abdullah4ai/apple-dev-docs feedback-statesInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Every view displaying async data MUST use a switch on Loadable<T> covering ALL 4 states. This is not optional.
switch viewModel.items {
case .notInitiated, .loading:
ProgressView("Loading...")
case .success(let items) where items.isEmpty:
ContentUnavailableView("No Items Yet", systemImage: "tray",
description: Text("Tap + to create your first item"))
case .success(let items):
List(items) { item in ItemRow(item: item) }
case .failure(let error):
ContentUnavailableView {
Label("Load Failed", systemImage: "exclamationmark.triangle")
} description: {
Text(error.localizedDescription)
} actions: {
Button("Retry") { Task { await viewModel.loadItems() } }
}
}
Rules:
if let to unwrap only the success case — all 4 states must be handledContentUnavailableView with an action buttonProgressViewEvery mutation button (save, delete, upload, send) MUST:
Button {
Task { await viewModel.save() }
} label: {
if viewModel.saveState.isLoading {
ProgressView()
.controlSize(.small)
} else {
Text("Save")
}
}
.disabled(viewModel.saveState.isLoading)
LOADING PATTERNS:
Button {
Task { await save() }
} label: {
if saveState.isLoading {
ProgressView()
.controlSize(.small)
} else {
Text("Save")
}
}
.disabled(saveState.isLoading)
switch viewModel.items {
case .notInitiated, .loading:
ProgressView("Loading...")
case .success(let items):
ContentListView(items: items)
case .failure(let error):
ErrorView(error: error) {
Task { await viewModel.loadItems() }
}
}
ForEach(Item.sampleData) { item in
ItemRow(item: item)
}
.redacted(reason: .placeholder)
List { ... }
.refreshable { await viewModel.refresh() }
.overlay {
if operationState.isLoading {
ZStack {
Color.black.opacity(0.3)
ProgressView()
.controlSize(.large)
.tint(.white)
}
.ignoresSafeArea()
}
}
LOADING RULES:
Loadable<T> for all async state — never var isLoading: Bool + var errorMessage: String?.ERROR HANDLING UI:
if let error = emailError {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.circle.fill")
Text(error)
}
.font(AppTheme.Fonts.caption)
.foregroundStyle(AppTheme.Colors.error)
}
.alert("Error", isPresented: $showError) {
Button("Retry") { Task { await retry() } }
Button("Cancel", role: .cancel) { }
} message: {
Text(errorMessage)
}
if let error = bannerError {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(AppTheme.Colors.warning)
Text(error)
.font(AppTheme.Fonts.subheadline)
Spacer()
Button("Dismiss") { bannerError = nil }
.font(AppTheme.Fonts.caption)
}
.padding(AppTheme.Spacing.small)
.background(.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, AppTheme.Spacing.medium)
}
ERROR HANDLING RULES:
SUCCESS FEEDBACK:
// Brief success animation
withAnimation(.spring(response: 0.3)) {
showSuccess = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation { showSuccess = false }
}
DISABLED STATE:
NETWORK/SYSTEM ERROR PATTERN (ViewModel with Loadable):
@MainActor @Observable
class ItemViewModel {
var items: Loadable<[Item]> = .notInitiated
func loadItems() async {
items = .loading
do {
items = .success(try await fetchItems())
} catch {
items = .failure(error)
}
}
var userFacingError: String? {
if case .failure = items {
return "Couldn't load items. Pull to refresh to try again."
}
return nil
}
}
EMPTY VS ERROR VS LOADING (switch on Loadable):
switch viewModel.items {
case .notInitiated, .loading:
ProgressView("Loading...")
case .success(let items) where items.isEmpty:
ContentUnavailableView("No Items Yet", systemImage: "tray",
description: Text("Tap + to create your first item"))
case .success(let items):
List(items) { item in ItemRow(item: item) }
case .failure(let error):
ContentUnavailableView {
Label("Load Failed", systemImage: "exclamationmark.triangle")
} description: {
Text(error.localizedDescription)
} actions: {
Button("Retry") { Task { await viewModel.loadItems() } }
}
}
Loadable<T> makes each state explicit and compiler-enforced via switch.testing
Use for 3D games: racing, 3D sports, board games, marble maze, tower defense, bowling. SceneKit + SceneView architecture, 3D scene hierarchy, physics, game loop, primitives, materials, cameras, particles, audio.
documentation
Game UI patterns: SwiftUI HUD overlays on SpriteKit, menus (main/pause/game-over), virtual joystick/d-pad, score displays, health bars, tutorial onboarding.
tools
Download free game sprites/textures/3D models and generate procedural assets. Covers nw_download_asset tool, texture factories, sprite atlas organization, 3D model loading, and programmatic asset creation.
testing
Use for 2D games: arcade, puzzle, sports, ping pong, platformer, shooter, 2D racing. SpriteKit + SpriteView architecture, scene hierarchy, physics, game loop, audio, particles, game feel.