From 3d4094bc82466e4d90e3b9498cdcb49d4754b79d Mon Sep 17 00:00:00 2001 From: Jack-sh1 Date: Mon, 27 Apr 2026 20:11:07 +0800 Subject: [PATCH 1/5] Add testing-previews.md reference for SwiftUI testing and preview patterns The skill currently has no coverage for testing or previews. Add a new reference covering #Preview macro usage and traits, @Previewable for inline bindings, mock data patterns for previews, XCTest strategies for @Observable models and UI tests, ViewInspector for view-layer unit testing, and snapshot testing with swift-snapshot-testing. --- swiftui-expert-skill/SKILL.md | 4 +- .../references/testing-previews.md | 643 ++++++++++++++++++ 2 files changed, 646 insertions(+), 1 deletion(-) create mode 100644 swiftui-expert-skill/references/testing-previews.md diff --git a/swiftui-expert-skill/SKILL.md b/swiftui-expert-skill/SKILL.md index f12241c..9ee91f5 100644 --- a/swiftui-expert-skill/SKILL.md +++ b/swiftui-expert-skill/SKILL.md @@ -63,6 +63,7 @@ Consult the reference file for each topic relevant to the current task: | macOS window styling | `references/macos-window-styling.md` | | macOS views | `references/macos-views.md` | | Deprecated API lookup | `references/latest-apis.md` | +| Testing and previews | `references/testing-previews.md` | ## Correctness Checklist @@ -78,7 +79,7 @@ These are hard rules -- violations are always bugs: - [ ] `.animation(_:value:)` always includes the `value` parameter - [ ] iOS 26+ APIs gated with `#available` and fallback provided - [ ] `import Charts` present in files using chart types - +- [ ] Previews use self-contained mock data; no dependency on live services or network ## References - `references/latest-apis.md` -- **Read first for every task.** Deprecated-to-modern API transitions (iOS 15+ through iOS 26+) @@ -100,3 +101,4 @@ These are hard rules -- violations are always bugs: - `references/macos-scenes.md` -- Settings, MenuBarExtra, WindowGroup, multi-window - `references/macos-window-styling.md` -- Toolbar styles, window sizing, Commands - `references/macos-views.md` -- HSplitView, Table, PasteButton, AppKit interop +- `references/testing-previews.md` -- `#Preview` macro, `@Previewable`, mock data patterns, XCTest/XCUITest, snapshot testing, @Observable model testing diff --git a/swiftui-expert-skill/references/testing-previews.md b/swiftui-expert-skill/references/testing-previews.md new file mode 100644 index 0000000..ee673d8 --- /dev/null +++ b/swiftui-expert-skill/references/testing-previews.md @@ -0,0 +1,643 @@ +# SwiftUI Testing & Previews Reference + +## Table of Contents + +- [Preview Macro](#preview-macro) +- [Preview with Mock Data](#preview-with-mock-data) +- [@Previewable Property Wrappers](#previewable-property-wrappers) +- [XCTest for SwiftUI Views](#xctest-for-swiftui-views) +- [Snapshot Testing](#snapshot-testing) +- [Testing @Observable Models](#testing-observable-models) +- [Common Diagnostics](#common-diagnostics) +- [Summary Checklist](#summary-checklist) + +--- + +## Preview Macro + +The `#Preview` macro (Swift 5.9+, Xcode 15+) replaces the legacy `PreviewProvider` protocol with a lightweight syntax. + +### Basic Usage + +```swift +// ✅ Modern: #Preview macro +#Preview { + ContentView() +} + +// ✅ Named preview +#Preview("Dark Mode") { + ContentView() + .preferredColorScheme(.dark) +} + +// ❌ Legacy: PreviewProvider (avoid for new code) +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } +} +``` + +### Multiple Previews + +```swift +#Preview("Default") { + SettingsRow(title: "Notifications", isOn: true) +} + +#Preview("Off State") { + SettingsRow(title: "Notifications", isOn: false) +} + +#Preview("Long Title") { + SettingsRow(title: "Enable Push Notifications for All Events", isOn: true) +} +``` + +### Preview Traits + +Use traits to configure the preview environment: + +```swift +// Fixed size +#Preview(traits: .fixedLayout(width: 300, height: 100)) { + CompactBanner(message: "Welcome") +} + +// Size that fits content +#Preview(traits: .sizeThatFitsLayout) { + BadgeView(count: 5) +} + +// Landscape orientation +#Preview(traits: .landscapeLeft) { + DashboardView() +} +``` + +### Previewing in NavigationStack + +```swift +#Preview { + NavigationStack { + DetailView(item: .sample) + } +} +``` + +### UIKit / AppKit Preview + +The `#Preview` macro also supports UIKit view controllers and views: + +```swift +// UIViewController +#Preview { + let vc = ProfileViewController() + vc.user = .sample + return vc +} + +// UIView +#Preview { + let label = UILabel() + label.text = "Hello" + label.textAlignment = .center + return label +} +``` + +--- + +## Preview with Mock Data + +Previews should use self-contained mock data that compiles without external dependencies. + +### Static Sample Data + +```swift +struct Item: Identifiable { + let id: UUID + var name: String + var price: Double +} + +extension Item { + static let sample = Item(id: UUID(), name: "Widget", price: 9.99) + + static let samples: [Item] = [ + Item(id: UUID(), name: "Widget", price: 9.99), + Item(id: UUID(), name: "Gadget", price: 19.99), + Item(id: UUID(), name: "Doohickey", price: 4.99), + ] +} + +#Preview { + ItemListView(items: Item.samples) +} +``` + +### Mock Observable Models + +```swift +@Observable +@MainActor +final class CartModel { + var items: [Item] = [] + var isLoading = false + + static var preview: CartModel { + let model = CartModel() + model.items = Item.samples + return model + } + + static var emptyPreview: CartModel { + CartModel() + } + + static var loadingPreview: CartModel { + let model = CartModel() + model.isLoading = true + return model + } +} + +#Preview("With Items") { + CartView() + .environment(CartModel.preview) +} + +#Preview("Empty") { + CartView() + .environment(CartModel.emptyPreview) +} + +#Preview("Loading") { + CartView() + .environment(CartModel.loadingPreview) +} +``` + +### Preview with Environment Dependencies + +```swift +#Preview { + OrderDetailView(order: .sample) + .environment(CartModel.preview) + .environment(\.locale, Locale(identifier: "ja_JP")) + .environment(\.dynamicTypeSize, .xxxLarge) +} +``` + +### Protocol-Based Mock Services + +For views that depend on network or data services, use protocols to inject mocks: + +```swift +protocol DataFetching: Sendable { + func fetchItems() async throws -> [Item] +} + +struct LiveDataFetcher: DataFetching { + func fetchItems() async throws -> [Item] { + // Real network call + try await URLSession.shared.decode([Item].self, from: endpoint) + } +} + +struct MockDataFetcher: DataFetching { + var result: Result<[Item], Error> = .success(Item.samples) + + func fetchItems() async throws -> [Item] { + try result.get() + } +} + +#Preview { + ItemListView(fetcher: MockDataFetcher()) +} + +#Preview("Error State") { + ItemListView(fetcher: MockDataFetcher(result: .failure(URLError(.notConnectedToInternet)))) +} +``` + +--- + +## @Previewable Property Wrappers + +`@Previewable` (iOS 18+, Xcode 16+) allows using `@State` and other property wrappers directly inside `#Preview` blocks, enabling interactive previews without wrapper views. + +### Interactive State + +```swift +// ✅ @Previewable: interactive toggle in preview +#Preview { + @Previewable @State var isOn = false + Toggle("Notifications", isOn: $isOn) +} + +// ❌ Without @Previewable: requires a wrapper view +struct TogglePreviewWrapper: View { + @State private var isOn = false + var body: some View { + Toggle("Notifications", isOn: $isOn) + } +} + +#Preview { + TogglePreviewWrapper() +} +``` + +### Multiple Interactive Controls + +```swift +#Preview { + @Previewable @State var name = "Alice" + @Previewable @State var age = 25.0 + + VStack { + TextField("Name", text: $name) + Slider(value: $age, in: 0...100, step: 1) { + Text("Age: \(Int(age))") + } + Text("Hello, \(name)! Age: \(Int(age))") + } + .padding() +} +``` + +### @Previewable with @FocusState + +```swift +#Preview { + @Previewable @FocusState var isFocused: Bool + + TextField("Search", text: .constant("")) + .focused($isFocused) + .onAppear { isFocused = true } +} +``` + +### Fallback for Pre-iOS 18 + +If targeting older OS versions, use wrapper views instead: + +```swift +private struct SliderPreview: View { + @State private var value = 0.5 + var body: some View { + CustomSlider(value: $value) + } +} + +#Preview { + SliderPreview() +} +``` + +--- + +## XCTest for SwiftUI Views + +SwiftUI views are value types describing UI — they don't have a DOM you can query directly. Test strategies focus on the model layer and use UI tests for integration. + +### Unit Testing @Observable Models + +The most effective approach: test the model logic that drives the view. + +```swift +import Testing + +@MainActor +struct CartModelTests { + @Test func addItemIncreasesCount() { + let cart = CartModel() + cart.addItem(.sample) + #expect(cart.items.count == 1) + } + + @Test func removeItemDecreasesTotal() { + let cart = CartModel() + cart.addItem(Item(id: UUID(), name: "Widget", price: 10.0)) + cart.removeAll() + #expect(cart.items.isEmpty) + #expect(cart.total == 0) + } + + @Test func totalComputesCorrectly() { + let cart = CartModel() + cart.addItem(Item(id: UUID(), name: "A", price: 10.0)) + cart.addItem(Item(id: UUID(), name: "B", price: 20.0)) + #expect(cart.total == 30.0) + } +} +``` + +### XCTest with Hosting (UIKit bridge) + +For cases where you need to verify the rendered view hierarchy: + +```swift +import XCTest +import SwiftUI + +final class BadgeViewTests: XCTestCase { + @MainActor + func testBadgeRendersInHostingController() throws { + let view = BadgeView(count: 5) + let hostingController = UIHostingController(rootView: view) + + // Force layout + hostingController.view.layoutIfNeeded() + + // Verify the hosting controller loaded without crash + XCTAssertNotNil(hostingController.view) + + // Check accessibility + let accessibilityLabel = hostingController.view.accessibilityLabel + XCTAssertEqual(accessibilityLabel, "5 notifications") + } +} +``` + +### UI Tests with XCUITest + +For end-to-end integration testing: + +```swift +import XCTest + +final class SettingsUITests: XCTestCase { + let app = XCUIApplication() + + override func setUpWithError() throws { + continueAfterFailure = false + app.launchArguments = ["--ui-testing"] + app.launch() + } + + @MainActor + func testToggleNotifications() throws { + let toggle = app.switches["Notifications"] + XCTAssertTrue(toggle.exists) + + toggle.tap() + XCTAssertEqual(toggle.value as? String, "1") + } + + @MainActor + func testNavigationToDetail() throws { + app.buttons["Settings"].tap() + XCTAssertTrue(app.navigationBars["Settings"].exists) + + app.cells.firstMatch.tap() + XCTAssertTrue(app.navigationBars["Detail"].exists) + } +} +``` + +### Accessibility Identifiers for Testing + +Add identifiers to make views findable in UI tests: + +```swift +struct ItemRow: View { + let item: Item + + var body: some View { + HStack { + Text(item.name) + Spacer() + Text(item.price, format: .currency(code: "USD")) + } + .accessibilityIdentifier("item-row-\(item.id)") + } +} +``` + +--- + +## Snapshot Testing + +Snapshot testing captures rendered images of views and compares them against stored references. Useful for catching unintended visual regressions. + +### Using swift-snapshot-testing + +[swift-snapshot-testing](https://github.com/pointfreeco/swift-snapshot-testing) is the most widely adopted library. + +**Setup** in `Package.swift`: + +```swift +.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.0") +``` + +### Writing Snapshot Tests + +```swift +import XCTest +import SnapshotTesting +import SwiftUI + +final class BadgeViewSnapshotTests: XCTestCase { + @MainActor + func testDefaultAppearance() { + let view = BadgeView(count: 3) + .frame(width: 50, height: 50) + + assertSnapshot( + of: UIHostingController(rootView: view), + as: .image(on: .iPhone13) + ) + } + + @MainActor + func testDarkMode() { + let view = BadgeView(count: 3) + .frame(width: 50, height: 50) + .preferredColorScheme(.dark) + + assertSnapshot( + of: UIHostingController(rootView: view), + as: .image(on: .iPhone13), + named: "dark" + ) + } + + @MainActor + func testLargeContentSize() { + let view = BadgeView(count: 3) + .frame(width: 100, height: 50) + .environment(\.dynamicTypeSize, .accessibility3) + + assertSnapshot( + of: UIHostingController(rootView: view), + as: .image(on: .iPhone13), + named: "a11y-xxxl" + ) + } +} +``` + +### Best Practices for Snapshots + +- **Record once, then compare**: First run creates the reference image; subsequent runs compare against it. +- **Test multiple configurations**: Light/dark mode, dynamic type sizes, locales, device sizes. +- **Keep snapshots deterministic**: Use fixed dates, mock network images, disable animations. +- **Review diffs in PR**: Store reference images in the repo so reviewers can see visual changes. +- **Don't over-snapshot**: Focus on complex or layout-critical views. Simple `Text` labels don't need snapshots. + +### Making Snapshots Deterministic + +```swift +@MainActor +func testOrderCard() { + // Fixed data — no random UUIDs or live dates + let order = Order( + id: "test-001", + date: Date(timeIntervalSince1970: 1_700_000_000), + items: [Item(id: "A", name: "Widget", price: 9.99)] + ) + + let view = OrderCard(order: order) + .frame(width: 350) + .environment(\.locale, Locale(identifier: "en_US")) + .environment(\.timeZone, TimeZone(identifier: "UTC")!) + + assertSnapshot(of: UIHostingController(rootView: view), as: .image) +} +``` + +--- + +## Testing @Observable Models + +### Async Method Testing + +```swift +import Testing + +@MainActor +struct ItemListModelTests { + @Test func fetchItemsPopulatesList() async throws { + let model = ItemListModel(fetcher: MockDataFetcher()) + #expect(model.items.isEmpty) + + await model.loadItems() + + #expect(model.items.count == 3) + #expect(!model.isLoading) + } + + @Test func fetchItemsHandlesError() async throws { + let failingFetcher = MockDataFetcher( + result: .failure(URLError(.notConnectedToInternet)) + ) + let model = ItemListModel(fetcher: failingFetcher) + + await model.loadItems() + + #expect(model.items.isEmpty) + #expect(model.errorMessage != nil) + } + + @Test func fetchItemsSetsLoadingState() async throws { + let model = ItemListModel(fetcher: MockDataFetcher()) + + // Before loading + #expect(!model.isLoading) + + // After loading completes + await model.loadItems() + #expect(!model.isLoading) + } +} +``` + +### Testing State Transitions + +```swift +@Observable +@MainActor +final class FormModel { + var email = "" + var password = "" + + var isValid: Bool { + email.contains("@") && password.count >= 8 + } + + enum SubmitState: Equatable { + case idle, submitting, success, failed(String) + } + + var submitState: SubmitState = .idle + + func submit() async { + guard isValid else { + submitState = .failed("Invalid input") + return + } + submitState = .submitting + do { + try await api.submit(email: email, password: password) + submitState = .success + } catch { + submitState = .failed(error.localizedDescription) + } + } +} + +@MainActor +struct FormModelTests { + @Test func validationRequiresAtSymbol() { + let form = FormModel() + form.email = "test" + form.password = "12345678" + #expect(!form.isValid) + + form.email = "test@example.com" + #expect(form.isValid) + } + + @Test func submitFailsWhenInvalid() async { + let form = FormModel() + form.email = "bad" + form.password = "short" + + await form.submit() + + #expect(form.submitState == .failed("Invalid input")) + } +} +``` + +--- + +## Common Diagnostics + +| Error / Warning | Cause | Fix | +|---|---|---| +| `#Preview` body type mismatch | Returning a non-`View` type from `#Preview` block | Ensure the block returns a `View` (or `UIViewController` for UIKit) | +| `@Previewable` only available in iOS 18+ | Using `@Previewable` with lower deployment target | Use a wrapper view instead, or gate with `#available` | +| Preview crashes with "missing environment" | `@Environment(SomeType.self)` not injected in preview | Add `.environment(SomeType.preview)` to the preview | +| Snapshot test fails on CI but passes locally | Different OS / simulator version renders differently | Pin CI to a specific Xcode and simulator version | +| `@MainActor`-isolated model in non-isolated test | Calling `@MainActor` model methods from sync test | Mark the test struct/class `@MainActor` or use `await` | +| Preview renders blank | View depends on async data that never loads | Use mock data with pre-populated state for previews | + +--- + +## Summary Checklist + +- [ ] Use `#Preview` macro instead of `PreviewProvider` for all new previews +- [ ] Provide named previews for key states (default, empty, error, loading) +- [ ] Use `@Previewable` for interactive previews (iOS 18+); wrapper views for older targets +- [ ] Supply static `.sample` / `.preview` data on models for previews +- [ ] Inject mock services via protocols for previews that depend on network / data +- [ ] Test `@Observable` model logic with Swift Testing (`@Test`) as the primary strategy +- [ ] Add `.accessibilityIdentifier` to views that need to be found in UI tests +- [ ] Use snapshot tests for complex or layout-critical views +- [ ] Keep snapshots deterministic: fixed data, fixed locale, mocked images +- [ ] Mark test structs `@MainActor` when testing `@MainActor`-isolated models From a44117b67a1db3b6d6e3fe03c74d523ac9581ac2 Mon Sep 17 00:00:00 2001 From: Jack-sh1 Date: Wed, 29 Apr 2026 16:18:59 +0800 Subject: [PATCH 2/5] fix: update Cursor plugin status in README The Cursor plugin configuration (.cursor-plugin/plugin.json) already exists and is versioned at 3.0.0, but the README still marked it as 'coming soon'. Update the section to reflect the current state. --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 228e0ff..d972158 100644 --- a/README.md +++ b/README.md @@ -49,10 +49,8 @@ To install this Skill for your personal use in Claude Code: /plugin install swiftui-expert@swiftui-expert-skill ``` -### Option C: Cursor plugin (coming soon) -This repository is now packaged for Cursor plugin submission, but the marketplace listing is not live yet. - -Once approved, you'll be able to install it from the Cursor Marketplace. +### Option C: Cursor plugin +This repository is packaged as a Cursor plugin. See [Cursor plugins documentation](https://cursor.com/docs/plugins) for installation instructions. #### Project Configuration To automatically provide this Skill to everyone working in a repository, configure the repository's `.claude/settings.json`: From 75875f56dc1329a1794a7d9aa27d7f8d5a28be0b Mon Sep 17 00:00:00 2001 From: Jack-sh1 Date: Sun, 3 May 2026 02:32:30 +0800 Subject: [PATCH 3/5] refactor: remove testing and preview changes, keep PR focused on README update --- swiftui-expert-skill/SKILL.md | 65 +- .../references/testing-previews.md | 643 ------------------ 2 files changed, 2 insertions(+), 706 deletions(-) delete mode 100644 swiftui-expert-skill/references/testing-previews.md diff --git a/swiftui-expert-skill/SKILL.md b/swiftui-expert-skill/SKILL.md index 9c31dc5..f12241c 100644 --- a/swiftui-expert-skill/SKILL.md +++ b/swiftui-expert-skill/SKILL.md @@ -1,6 +1,6 @@ --- name: swiftui-expert-skill -description: Write, review, or improve SwiftUI code following best practices for state management, view composition, performance, macOS-specific APIs, and iOS 26+ Liquid Glass adoption. Use when building new SwiftUI features, refactoring existing views, reviewing code quality, or adopting modern SwiftUI patterns. Also triggers whenever an Xcode Instruments `.trace` file is referenced (to analyse it) or the user asks to **record** a new trace — attach to a running app, launch one fresh, or capture a manually-stopped session with the bundled `record_trace.py`. A target SwiftUI source file is optional; if provided it grounds recommendations in specific lines, but a trace alone is enough to diagnose hangs, hitches, CPU hotspots, and high-severity SwiftUI updates. +description: Write, review, or improve SwiftUI code following best practices for state management, view composition, performance, macOS-specific APIs, and iOS 26+ Liquid Glass adoption. Use when building new SwiftUI features, refactoring existing views, reviewing code quality, or adopting modern SwiftUI patterns. --- # SwiftUI Expert Skill @@ -38,55 +38,6 @@ description: Write, review, or improve SwiftUI code following best practices for - Use `Button` for all tappable elements; add accessibility grouping and labels - Gate version-specific APIs with `#available` and provide fallbacks -### Record a new Instruments trace -Trigger when the user asks to "record a trace", "profile the app", "capture a session", etc. Full reference: `references/trace-recording.md`. - -1. **Confirm target** — attach to a running app, launch an app, or record all processes? If the user didn't say, ask. List connected devices when useful: - ```bash - python3 "${SKILL_DIR}/scripts/record_trace.py" --list-devices - ``` -2. **Pick a template based on target kind** — the `SwiftUI` template populates the SwiftUI lane on any **real device**: a physical iOS/iPadOS device **or the host Mac**. The only exception is the **iOS Simulator**, where the SwiftUI lane comes back empty — switch to `--template "Time Profiler"` in that case (still gives Time Profiler + Hangs + Animation Hitches). Always check `--list-devices`: `simulators` kind → `Time Profiler`; `devices` kind (real devices and the host Mac) → default `SwiftUI`. Full decision table in `references/trace-recording.md`. -3. **Start the recording**. For agent-driven sessions where the user says "I'll tell you when I'm done", start in the background and use a stop-file: - ```bash - python3 "${SKILL_DIR}/scripts/record_trace.py" \ - --device "" --attach "" \ - --stop-file /tmp/stop-trace --output ~/Desktop/session.trace - ``` - For interactive sessions, just tell the user to press Ctrl+C when done. -4. **Signal stop** — when the user says they've finished exercising the app, `touch /tmp/stop-trace`. The script cleanly SIGINTs xctrace and waits up to 60s for finalisation. -5. **Analyse** the resulting trace (flow into the "Trace-driven improvement" workflow below). - -### Trace-driven improvement (Instruments `.trace` provided) -Trigger whenever the user's request references a `.trace` file. A target SwiftUI source file is **optional** — if given, cite specific lines; if not, recommend where to look based on view names and symbols the trace already reveals. - -Full reference: `references/trace-analysis.md`. Summary of the composition pattern: - -1. **Scope the analysis.** Ask yourself: does the user want the whole trace, or a slice? - - "focus on X / after X / between X and Y / during X" → **resolve to a window first** (see step 2). - - No scoping cue → analyse the whole trace. -2. **Resolve a window (only if the user scoped).** The parser exposes two discovery modes: - ```bash - # Find a log that marks the start/end of the region of interest: - python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace \ - --list-logs --log-message-contains "loaded feed" --log-limit 5 - # Or list os_signpost intervals (paired begin/end), filterable by name: - python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace \ - --list-signposts --signpost-name-contains "ImageDecode" - ``` - Both modes accept `--window START_MS:END_MS` to scope discovery. Pick the `time_ms` (for logs) or `start_ms`/`end_ms` (for signposts) that match the user's description. Build a window like `--window 10400:11700`. -3. **Run the main analysis** (with or without `--window`): - ```bash - python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace \ - --json-only --top 10 [--window START_MS:END_MS] - ``` -4. **Interpret with `references/trace-analysis.md`** — key diagnostics: - - `main_running_coverage_pct` inside each correlation (<25% = blocked; ≥75% = CPU-bound). - - `swiftui-causes.top_sources` reveals *why* updates keep happening — high-edge-count sources like `UserDefaultObserver.send()` or wide `EnvironmentWriter` entries are structural invalidation bugs. Fixing one often collapses many downstream hot views. -5. **When a specific view shows as expensive, ask who's invalidating it.** Use `--fanin-for ""` to get the ranked list of source nodes driving the updates. -6. **Optionally ground in source.** If the user pointed at a file, read it and match view names / user-code symbols against identifiers there. If not, recommend which files to open based on the view names SwiftUI reported. -7. **Return a prioritised plan.** Cite evidence (coverage %, hot symbol, overlapping view, log timestamp, cause-graph edges) and route each recommendation to a Topic Router reference. -8. Only edit code if the user asked for edits. - ### Topic Router Consult the reference file for each topic relevant to the current task: @@ -100,7 +51,6 @@ Consult the reference file for each topic relevant to the current task: | Layout | `references/layout-best-practices.md` | | Sheets and navigation | `references/sheet-navigation-patterns.md` | | ScrollView | `references/scroll-patterns.md` | -| Focus management | `references/focus-patterns.md` | | Animations (basics) | `references/animation-basics.md` | | Animations (transitions) | `references/animation-transitions.md` | | Animations (advanced) | `references/animation-advanced.md` | @@ -112,11 +62,7 @@ Consult the reference file for each topic relevant to the current task: | macOS scenes | `references/macos-scenes.md` | | macOS window styling | `references/macos-window-styling.md` | | macOS views | `references/macos-views.md` | -| Text patterns | `references/text-patterns.md` | | Deprecated API lookup | `references/latest-apis.md` | -| Testing and previews | `references/testing-previews.md` | -| Instruments trace analysis | `references/trace-analysis.md` | -| Instruments trace recording | `references/trace-recording.md` | ## Correctness Checklist @@ -130,11 +76,9 @@ These are hard rules -- violations are always bugs: - [ ] `ForEach` uses stable identity (never `.indices` for dynamic content) - [ ] Constant number of views per `ForEach` element - [ ] `.animation(_:value:)` always includes the `value` parameter -- [ ] `@FocusState` properties are `private` -- [ ] No redundant `@FocusState` writes inside tap gesture handlers on `.focusable()` views - [ ] iOS 26+ APIs gated with `#available` and fallback provided - [ ] `import Charts` present in files using chart types -- [ ] Previews use self-contained mock data; no dependency on live services or network + ## References - `references/latest-apis.md` -- **Read first for every task.** Deprecated-to-modern API transitions (iOS 15+ through iOS 26+) @@ -151,13 +95,8 @@ These are hard rules -- violations are always bugs: - `references/charts-accessibility.md` -- Charts VoiceOver, Audio Graph, fallback strategies - `references/sheet-navigation-patterns.md` -- Sheets, NavigationSplitView, Inspector - `references/scroll-patterns.md` -- ScrollViewReader, programmatic scrolling -- `references/focus-patterns.md` -- Focus state, focusable views, focused values, default focus, common pitfalls - `references/image-optimization.md` -- AsyncImage, downsampling, caching - `references/liquid-glass.md` -- iOS 26+ Liquid Glass effects and fallback patterns - `references/macos-scenes.md` -- Settings, MenuBarExtra, WindowGroup, multi-window - `references/macos-window-styling.md` -- Toolbar styles, window sizing, Commands - `references/macos-views.md` -- HSplitView, Table, PasteButton, AppKit interop -- `references/testing-previews.md` -- `#Preview` macro, `@Previewable`, mock data patterns, XCTest/XCUITest, snapshot testing, @Observable model testing -- `references/text-patterns.md` -- Text initializer selection, verbatim vs localized -- `references/trace-analysis.md` -- Parse Instruments `.trace` files via `scripts/analyze_trace.py`; interpret main-thread coverage, high-severity SwiftUI updates, hitch narratives, and map findings back to source files -- `references/trace-recording.md` -- Record a new trace via `scripts/record_trace.py`: attach to a running app, launch one fresh, or capture a manually-stopped session; supports stop-file for agent-driven flows diff --git a/swiftui-expert-skill/references/testing-previews.md b/swiftui-expert-skill/references/testing-previews.md deleted file mode 100644 index ee673d8..0000000 --- a/swiftui-expert-skill/references/testing-previews.md +++ /dev/null @@ -1,643 +0,0 @@ -# SwiftUI Testing & Previews Reference - -## Table of Contents - -- [Preview Macro](#preview-macro) -- [Preview with Mock Data](#preview-with-mock-data) -- [@Previewable Property Wrappers](#previewable-property-wrappers) -- [XCTest for SwiftUI Views](#xctest-for-swiftui-views) -- [Snapshot Testing](#snapshot-testing) -- [Testing @Observable Models](#testing-observable-models) -- [Common Diagnostics](#common-diagnostics) -- [Summary Checklist](#summary-checklist) - ---- - -## Preview Macro - -The `#Preview` macro (Swift 5.9+, Xcode 15+) replaces the legacy `PreviewProvider` protocol with a lightweight syntax. - -### Basic Usage - -```swift -// ✅ Modern: #Preview macro -#Preview { - ContentView() -} - -// ✅ Named preview -#Preview("Dark Mode") { - ContentView() - .preferredColorScheme(.dark) -} - -// ❌ Legacy: PreviewProvider (avoid for new code) -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } -} -``` - -### Multiple Previews - -```swift -#Preview("Default") { - SettingsRow(title: "Notifications", isOn: true) -} - -#Preview("Off State") { - SettingsRow(title: "Notifications", isOn: false) -} - -#Preview("Long Title") { - SettingsRow(title: "Enable Push Notifications for All Events", isOn: true) -} -``` - -### Preview Traits - -Use traits to configure the preview environment: - -```swift -// Fixed size -#Preview(traits: .fixedLayout(width: 300, height: 100)) { - CompactBanner(message: "Welcome") -} - -// Size that fits content -#Preview(traits: .sizeThatFitsLayout) { - BadgeView(count: 5) -} - -// Landscape orientation -#Preview(traits: .landscapeLeft) { - DashboardView() -} -``` - -### Previewing in NavigationStack - -```swift -#Preview { - NavigationStack { - DetailView(item: .sample) - } -} -``` - -### UIKit / AppKit Preview - -The `#Preview` macro also supports UIKit view controllers and views: - -```swift -// UIViewController -#Preview { - let vc = ProfileViewController() - vc.user = .sample - return vc -} - -// UIView -#Preview { - let label = UILabel() - label.text = "Hello" - label.textAlignment = .center - return label -} -``` - ---- - -## Preview with Mock Data - -Previews should use self-contained mock data that compiles without external dependencies. - -### Static Sample Data - -```swift -struct Item: Identifiable { - let id: UUID - var name: String - var price: Double -} - -extension Item { - static let sample = Item(id: UUID(), name: "Widget", price: 9.99) - - static let samples: [Item] = [ - Item(id: UUID(), name: "Widget", price: 9.99), - Item(id: UUID(), name: "Gadget", price: 19.99), - Item(id: UUID(), name: "Doohickey", price: 4.99), - ] -} - -#Preview { - ItemListView(items: Item.samples) -} -``` - -### Mock Observable Models - -```swift -@Observable -@MainActor -final class CartModel { - var items: [Item] = [] - var isLoading = false - - static var preview: CartModel { - let model = CartModel() - model.items = Item.samples - return model - } - - static var emptyPreview: CartModel { - CartModel() - } - - static var loadingPreview: CartModel { - let model = CartModel() - model.isLoading = true - return model - } -} - -#Preview("With Items") { - CartView() - .environment(CartModel.preview) -} - -#Preview("Empty") { - CartView() - .environment(CartModel.emptyPreview) -} - -#Preview("Loading") { - CartView() - .environment(CartModel.loadingPreview) -} -``` - -### Preview with Environment Dependencies - -```swift -#Preview { - OrderDetailView(order: .sample) - .environment(CartModel.preview) - .environment(\.locale, Locale(identifier: "ja_JP")) - .environment(\.dynamicTypeSize, .xxxLarge) -} -``` - -### Protocol-Based Mock Services - -For views that depend on network or data services, use protocols to inject mocks: - -```swift -protocol DataFetching: Sendable { - func fetchItems() async throws -> [Item] -} - -struct LiveDataFetcher: DataFetching { - func fetchItems() async throws -> [Item] { - // Real network call - try await URLSession.shared.decode([Item].self, from: endpoint) - } -} - -struct MockDataFetcher: DataFetching { - var result: Result<[Item], Error> = .success(Item.samples) - - func fetchItems() async throws -> [Item] { - try result.get() - } -} - -#Preview { - ItemListView(fetcher: MockDataFetcher()) -} - -#Preview("Error State") { - ItemListView(fetcher: MockDataFetcher(result: .failure(URLError(.notConnectedToInternet)))) -} -``` - ---- - -## @Previewable Property Wrappers - -`@Previewable` (iOS 18+, Xcode 16+) allows using `@State` and other property wrappers directly inside `#Preview` blocks, enabling interactive previews without wrapper views. - -### Interactive State - -```swift -// ✅ @Previewable: interactive toggle in preview -#Preview { - @Previewable @State var isOn = false - Toggle("Notifications", isOn: $isOn) -} - -// ❌ Without @Previewable: requires a wrapper view -struct TogglePreviewWrapper: View { - @State private var isOn = false - var body: some View { - Toggle("Notifications", isOn: $isOn) - } -} - -#Preview { - TogglePreviewWrapper() -} -``` - -### Multiple Interactive Controls - -```swift -#Preview { - @Previewable @State var name = "Alice" - @Previewable @State var age = 25.0 - - VStack { - TextField("Name", text: $name) - Slider(value: $age, in: 0...100, step: 1) { - Text("Age: \(Int(age))") - } - Text("Hello, \(name)! Age: \(Int(age))") - } - .padding() -} -``` - -### @Previewable with @FocusState - -```swift -#Preview { - @Previewable @FocusState var isFocused: Bool - - TextField("Search", text: .constant("")) - .focused($isFocused) - .onAppear { isFocused = true } -} -``` - -### Fallback for Pre-iOS 18 - -If targeting older OS versions, use wrapper views instead: - -```swift -private struct SliderPreview: View { - @State private var value = 0.5 - var body: some View { - CustomSlider(value: $value) - } -} - -#Preview { - SliderPreview() -} -``` - ---- - -## XCTest for SwiftUI Views - -SwiftUI views are value types describing UI — they don't have a DOM you can query directly. Test strategies focus on the model layer and use UI tests for integration. - -### Unit Testing @Observable Models - -The most effective approach: test the model logic that drives the view. - -```swift -import Testing - -@MainActor -struct CartModelTests { - @Test func addItemIncreasesCount() { - let cart = CartModel() - cart.addItem(.sample) - #expect(cart.items.count == 1) - } - - @Test func removeItemDecreasesTotal() { - let cart = CartModel() - cart.addItem(Item(id: UUID(), name: "Widget", price: 10.0)) - cart.removeAll() - #expect(cart.items.isEmpty) - #expect(cart.total == 0) - } - - @Test func totalComputesCorrectly() { - let cart = CartModel() - cart.addItem(Item(id: UUID(), name: "A", price: 10.0)) - cart.addItem(Item(id: UUID(), name: "B", price: 20.0)) - #expect(cart.total == 30.0) - } -} -``` - -### XCTest with Hosting (UIKit bridge) - -For cases where you need to verify the rendered view hierarchy: - -```swift -import XCTest -import SwiftUI - -final class BadgeViewTests: XCTestCase { - @MainActor - func testBadgeRendersInHostingController() throws { - let view = BadgeView(count: 5) - let hostingController = UIHostingController(rootView: view) - - // Force layout - hostingController.view.layoutIfNeeded() - - // Verify the hosting controller loaded without crash - XCTAssertNotNil(hostingController.view) - - // Check accessibility - let accessibilityLabel = hostingController.view.accessibilityLabel - XCTAssertEqual(accessibilityLabel, "5 notifications") - } -} -``` - -### UI Tests with XCUITest - -For end-to-end integration testing: - -```swift -import XCTest - -final class SettingsUITests: XCTestCase { - let app = XCUIApplication() - - override func setUpWithError() throws { - continueAfterFailure = false - app.launchArguments = ["--ui-testing"] - app.launch() - } - - @MainActor - func testToggleNotifications() throws { - let toggle = app.switches["Notifications"] - XCTAssertTrue(toggle.exists) - - toggle.tap() - XCTAssertEqual(toggle.value as? String, "1") - } - - @MainActor - func testNavigationToDetail() throws { - app.buttons["Settings"].tap() - XCTAssertTrue(app.navigationBars["Settings"].exists) - - app.cells.firstMatch.tap() - XCTAssertTrue(app.navigationBars["Detail"].exists) - } -} -``` - -### Accessibility Identifiers for Testing - -Add identifiers to make views findable in UI tests: - -```swift -struct ItemRow: View { - let item: Item - - var body: some View { - HStack { - Text(item.name) - Spacer() - Text(item.price, format: .currency(code: "USD")) - } - .accessibilityIdentifier("item-row-\(item.id)") - } -} -``` - ---- - -## Snapshot Testing - -Snapshot testing captures rendered images of views and compares them against stored references. Useful for catching unintended visual regressions. - -### Using swift-snapshot-testing - -[swift-snapshot-testing](https://github.com/pointfreeco/swift-snapshot-testing) is the most widely adopted library. - -**Setup** in `Package.swift`: - -```swift -.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.0") -``` - -### Writing Snapshot Tests - -```swift -import XCTest -import SnapshotTesting -import SwiftUI - -final class BadgeViewSnapshotTests: XCTestCase { - @MainActor - func testDefaultAppearance() { - let view = BadgeView(count: 3) - .frame(width: 50, height: 50) - - assertSnapshot( - of: UIHostingController(rootView: view), - as: .image(on: .iPhone13) - ) - } - - @MainActor - func testDarkMode() { - let view = BadgeView(count: 3) - .frame(width: 50, height: 50) - .preferredColorScheme(.dark) - - assertSnapshot( - of: UIHostingController(rootView: view), - as: .image(on: .iPhone13), - named: "dark" - ) - } - - @MainActor - func testLargeContentSize() { - let view = BadgeView(count: 3) - .frame(width: 100, height: 50) - .environment(\.dynamicTypeSize, .accessibility3) - - assertSnapshot( - of: UIHostingController(rootView: view), - as: .image(on: .iPhone13), - named: "a11y-xxxl" - ) - } -} -``` - -### Best Practices for Snapshots - -- **Record once, then compare**: First run creates the reference image; subsequent runs compare against it. -- **Test multiple configurations**: Light/dark mode, dynamic type sizes, locales, device sizes. -- **Keep snapshots deterministic**: Use fixed dates, mock network images, disable animations. -- **Review diffs in PR**: Store reference images in the repo so reviewers can see visual changes. -- **Don't over-snapshot**: Focus on complex or layout-critical views. Simple `Text` labels don't need snapshots. - -### Making Snapshots Deterministic - -```swift -@MainActor -func testOrderCard() { - // Fixed data — no random UUIDs or live dates - let order = Order( - id: "test-001", - date: Date(timeIntervalSince1970: 1_700_000_000), - items: [Item(id: "A", name: "Widget", price: 9.99)] - ) - - let view = OrderCard(order: order) - .frame(width: 350) - .environment(\.locale, Locale(identifier: "en_US")) - .environment(\.timeZone, TimeZone(identifier: "UTC")!) - - assertSnapshot(of: UIHostingController(rootView: view), as: .image) -} -``` - ---- - -## Testing @Observable Models - -### Async Method Testing - -```swift -import Testing - -@MainActor -struct ItemListModelTests { - @Test func fetchItemsPopulatesList() async throws { - let model = ItemListModel(fetcher: MockDataFetcher()) - #expect(model.items.isEmpty) - - await model.loadItems() - - #expect(model.items.count == 3) - #expect(!model.isLoading) - } - - @Test func fetchItemsHandlesError() async throws { - let failingFetcher = MockDataFetcher( - result: .failure(URLError(.notConnectedToInternet)) - ) - let model = ItemListModel(fetcher: failingFetcher) - - await model.loadItems() - - #expect(model.items.isEmpty) - #expect(model.errorMessage != nil) - } - - @Test func fetchItemsSetsLoadingState() async throws { - let model = ItemListModel(fetcher: MockDataFetcher()) - - // Before loading - #expect(!model.isLoading) - - // After loading completes - await model.loadItems() - #expect(!model.isLoading) - } -} -``` - -### Testing State Transitions - -```swift -@Observable -@MainActor -final class FormModel { - var email = "" - var password = "" - - var isValid: Bool { - email.contains("@") && password.count >= 8 - } - - enum SubmitState: Equatable { - case idle, submitting, success, failed(String) - } - - var submitState: SubmitState = .idle - - func submit() async { - guard isValid else { - submitState = .failed("Invalid input") - return - } - submitState = .submitting - do { - try await api.submit(email: email, password: password) - submitState = .success - } catch { - submitState = .failed(error.localizedDescription) - } - } -} - -@MainActor -struct FormModelTests { - @Test func validationRequiresAtSymbol() { - let form = FormModel() - form.email = "test" - form.password = "12345678" - #expect(!form.isValid) - - form.email = "test@example.com" - #expect(form.isValid) - } - - @Test func submitFailsWhenInvalid() async { - let form = FormModel() - form.email = "bad" - form.password = "short" - - await form.submit() - - #expect(form.submitState == .failed("Invalid input")) - } -} -``` - ---- - -## Common Diagnostics - -| Error / Warning | Cause | Fix | -|---|---|---| -| `#Preview` body type mismatch | Returning a non-`View` type from `#Preview` block | Ensure the block returns a `View` (or `UIViewController` for UIKit) | -| `@Previewable` only available in iOS 18+ | Using `@Previewable` with lower deployment target | Use a wrapper view instead, or gate with `#available` | -| Preview crashes with "missing environment" | `@Environment(SomeType.self)` not injected in preview | Add `.environment(SomeType.preview)` to the preview | -| Snapshot test fails on CI but passes locally | Different OS / simulator version renders differently | Pin CI to a specific Xcode and simulator version | -| `@MainActor`-isolated model in non-isolated test | Calling `@MainActor` model methods from sync test | Mark the test struct/class `@MainActor` or use `await` | -| Preview renders blank | View depends on async data that never loads | Use mock data with pre-populated state for previews | - ---- - -## Summary Checklist - -- [ ] Use `#Preview` macro instead of `PreviewProvider` for all new previews -- [ ] Provide named previews for key states (default, empty, error, loading) -- [ ] Use `@Previewable` for interactive previews (iOS 18+); wrapper views for older targets -- [ ] Supply static `.sample` / `.preview` data on models for previews -- [ ] Inject mock services via protocols for previews that depend on network / data -- [ ] Test `@Observable` model logic with Swift Testing (`@Test`) as the primary strategy -- [ ] Add `.accessibilityIdentifier` to views that need to be found in UI tests -- [ ] Use snapshot tests for complex or layout-critical views -- [ ] Keep snapshots deterministic: fixed data, fixed locale, mocked images -- [ ] Mark test structs `@MainActor` when testing `@MainActor`-isolated models From 620d70a7044ee52f416d1fb4b8090c07fc037538 Mon Sep 17 00:00:00 2001 From: Jack-sh1 Date: Sun, 10 May 2026 01:16:51 +0800 Subject: [PATCH 4/5] Revert unintended changes to SKILL.md, keep only README update --- swiftui-expert-skill/SKILL.md | 62 ++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/swiftui-expert-skill/SKILL.md b/swiftui-expert-skill/SKILL.md index f12241c..c60744c 100644 --- a/swiftui-expert-skill/SKILL.md +++ b/swiftui-expert-skill/SKILL.md @@ -1,6 +1,7 @@ --- name: swiftui-expert-skill -description: Write, review, or improve SwiftUI code following best practices for state management, view composition, performance, macOS-specific APIs, and iOS 26+ Liquid Glass adoption. Use when building new SwiftUI features, refactoring existing views, reviewing code quality, or adopting modern SwiftUI patterns. +description: Use when writing, reviewing, or refactoring SwiftUI code for iOS or macOS, including state management, view composition, performance, Liquid Glass adoption, or Instruments `.trace` capture/analysis for hangs, hitches, CPU hotspots, or + excessive view updates. --- # SwiftUI Expert Skill @@ -38,6 +39,55 @@ description: Write, review, or improve SwiftUI code following best practices for - Use `Button` for all tappable elements; add accessibility grouping and labels - Gate version-specific APIs with `#available` and provide fallbacks +### Record a new Instruments trace +Trigger when the user asks to "record a trace", "profile the app", "capture a session", etc. Full reference: `references/trace-recording.md`. + +1. **Confirm target** — attach to a running app, launch an app, or record all processes? If the user didn't say, ask. List connected devices when useful: + ```bash + python3 "${SKILL_DIR}/scripts/record_trace.py" --list-devices + ``` +2. **Pick a template based on target kind** — the `SwiftUI` template populates the SwiftUI lane on any **real device**: a physical iOS/iPadOS device **or the host Mac**. The only exception is the **iOS Simulator**, where the SwiftUI lane comes back empty — switch to `--template "Time Profiler"` in that case (still gives Time Profiler + Hangs + Animation Hitches). Always check `--list-devices`: `simulators` kind → `Time Profiler`; `devices` kind (real devices and the host Mac) → default `SwiftUI`. Full decision table in `references/trace-recording.md`. +3. **Start the recording**. For agent-driven sessions where the user says "I'll tell you when I'm done", start in the background and use a stop-file: + ```bash + python3 "${SKILL_DIR}/scripts/record_trace.py" \ + --device "" --attach "" \ + --stop-file /tmp/stop-trace --output ~/Desktop/session.trace + ``` + For interactive sessions, just tell the user to press Ctrl+C when done. +4. **Signal stop** — when the user says they've finished exercising the app, `touch /tmp/stop-trace`. The script cleanly SIGINTs xctrace and waits up to 60s for finalisation. +5. **Analyse** the resulting trace (flow into the "Trace-driven improvement" workflow below). + +### Trace-driven improvement (Instruments `.trace` provided) +Trigger whenever the user's request references a `.trace` file. A target SwiftUI source file is **optional** — if given, cite specific lines; if not, recommend where to look based on view names and symbols the trace already reveals. + +Full reference: `references/trace-analysis.md`. Summary of the composition pattern: + +1. **Scope the analysis.** Ask yourself: does the user want the whole trace, or a slice? + - "focus on X / after X / between X and Y / during X" → **resolve to a window first** (see step 2). + - No scoping cue → analyse the whole trace. +2. **Resolve a window (only if the user scoped).** The parser exposes two discovery modes: + ```bash + # Find a log that marks the start/end of the region of interest: + python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace \ + --list-logs --log-message-contains "loaded feed" --log-limit 5 + # Or list os_signpost intervals (paired begin/end), filterable by name: + python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace \ + --list-signposts --signpost-name-contains "ImageDecode" + ``` + Both modes accept `--window START_MS:END_MS` to scope discovery. Pick the `time_ms` (for logs) or `start_ms`/`end_ms` (for signposts) that match the user's description. Build a window like `--window 10400:11700`. +3. **Run the main analysis** (with or without `--window`): + ```bash + python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace \ + --json-only --top 10 [--window START_MS:END_MS] + ``` +4. **Interpret with `references/trace-analysis.md`** — key diagnostics: + - `main_running_coverage_pct` inside each correlation (<25% = blocked; ≥75% = CPU-bound). + - `swiftui-causes.top_sources` reveals *why* updates keep happening — high-edge-count sources like `UserDefaultObserver.send()` or wide `EnvironmentWriter` entries are structural invalidation bugs. Fixing one often collapses many downstream hot views. +5. **When a specific view shows as expensive, ask who's invalidating it.** Use `--fanin-for ""` to get the ranked list of source nodes driving the updates. +6. **Optionally ground in source.** If the user pointed at a file, read it and match view names / user-code symbols against identifiers there. If not, recommend which files to open based on the view names SwiftUI reported. +7. **Return a prioritised plan.** Cite evidence (coverage %, hot symbol, overlapping view, log timestamp, cause-graph edges) and route each recommendation to a Topic Router reference. +8. Only edit code if the user asked for edits. + ### Topic Router Consult the reference file for each topic relevant to the current task: @@ -51,6 +101,7 @@ Consult the reference file for each topic relevant to the current task: | Layout | `references/layout-best-practices.md` | | Sheets and navigation | `references/sheet-navigation-patterns.md` | | ScrollView | `references/scroll-patterns.md` | +| Focus management | `references/focus-patterns.md` | | Animations (basics) | `references/animation-basics.md` | | Animations (transitions) | `references/animation-transitions.md` | | Animations (advanced) | `references/animation-advanced.md` | @@ -62,7 +113,10 @@ Consult the reference file for each topic relevant to the current task: | macOS scenes | `references/macos-scenes.md` | | macOS window styling | `references/macos-window-styling.md` | | macOS views | `references/macos-views.md` | +| Text patterns | `references/text-patterns.md` | | Deprecated API lookup | `references/latest-apis.md` | +| Instruments trace analysis | `references/trace-analysis.md` | +| Instruments trace recording | `references/trace-recording.md` | ## Correctness Checklist @@ -76,6 +130,8 @@ These are hard rules -- violations are always bugs: - [ ] `ForEach` uses stable identity (never `.indices` for dynamic content) - [ ] Constant number of views per `ForEach` element - [ ] `.animation(_:value:)` always includes the `value` parameter +- [ ] `@FocusState` properties are `private` +- [ ] No redundant `@FocusState` writes inside tap gesture handlers on `.focusable()` views - [ ] iOS 26+ APIs gated with `#available` and fallback provided - [ ] `import Charts` present in files using chart types @@ -95,8 +151,12 @@ These are hard rules -- violations are always bugs: - `references/charts-accessibility.md` -- Charts VoiceOver, Audio Graph, fallback strategies - `references/sheet-navigation-patterns.md` -- Sheets, NavigationSplitView, Inspector - `references/scroll-patterns.md` -- ScrollViewReader, programmatic scrolling +- `references/focus-patterns.md` -- Focus state, focusable views, focused values, default focus, common pitfalls - `references/image-optimization.md` -- AsyncImage, downsampling, caching - `references/liquid-glass.md` -- iOS 26+ Liquid Glass effects and fallback patterns - `references/macos-scenes.md` -- Settings, MenuBarExtra, WindowGroup, multi-window - `references/macos-window-styling.md` -- Toolbar styles, window sizing, Commands - `references/macos-views.md` -- HSplitView, Table, PasteButton, AppKit interop +- `references/text-patterns.md` -- Text initializer selection, verbatim vs localized +- `references/trace-analysis.md` -- Parse Instruments `.trace` files via `scripts/analyze_trace.py`; interpret main-thread coverage, high-severity SwiftUI updates, hitch narratives, and map findings back to source files +- `references/trace-recording.md` -- Record a new trace via `scripts/record_trace.py`: attach to a running app, launch one fresh, or capture a manually-stopped session; supports stop-file for agent-driven flows From 53ef0c04177d85d3f1873402e3f8e0b0741f91df Mon Sep 17 00:00:00 2001 From: Jack-sh1 Date: Sun, 17 May 2026 23:19:34 +0800 Subject: [PATCH 5/5] revert SKILL.md description to latest main version --- swiftui-expert-skill/SKILL.md | 62 +---------------------------------- 1 file changed, 1 insertion(+), 61 deletions(-) diff --git a/swiftui-expert-skill/SKILL.md b/swiftui-expert-skill/SKILL.md index c60744c..f12241c 100644 --- a/swiftui-expert-skill/SKILL.md +++ b/swiftui-expert-skill/SKILL.md @@ -1,7 +1,6 @@ --- name: swiftui-expert-skill -description: Use when writing, reviewing, or refactoring SwiftUI code for iOS or macOS, including state management, view composition, performance, Liquid Glass adoption, or Instruments `.trace` capture/analysis for hangs, hitches, CPU hotspots, or - excessive view updates. +description: Write, review, or improve SwiftUI code following best practices for state management, view composition, performance, macOS-specific APIs, and iOS 26+ Liquid Glass adoption. Use when building new SwiftUI features, refactoring existing views, reviewing code quality, or adopting modern SwiftUI patterns. --- # SwiftUI Expert Skill @@ -39,55 +38,6 @@ description: Use when writing, reviewing, or refactoring SwiftUI code for iOS or - Use `Button` for all tappable elements; add accessibility grouping and labels - Gate version-specific APIs with `#available` and provide fallbacks -### Record a new Instruments trace -Trigger when the user asks to "record a trace", "profile the app", "capture a session", etc. Full reference: `references/trace-recording.md`. - -1. **Confirm target** — attach to a running app, launch an app, or record all processes? If the user didn't say, ask. List connected devices when useful: - ```bash - python3 "${SKILL_DIR}/scripts/record_trace.py" --list-devices - ``` -2. **Pick a template based on target kind** — the `SwiftUI` template populates the SwiftUI lane on any **real device**: a physical iOS/iPadOS device **or the host Mac**. The only exception is the **iOS Simulator**, where the SwiftUI lane comes back empty — switch to `--template "Time Profiler"` in that case (still gives Time Profiler + Hangs + Animation Hitches). Always check `--list-devices`: `simulators` kind → `Time Profiler`; `devices` kind (real devices and the host Mac) → default `SwiftUI`. Full decision table in `references/trace-recording.md`. -3. **Start the recording**. For agent-driven sessions where the user says "I'll tell you when I'm done", start in the background and use a stop-file: - ```bash - python3 "${SKILL_DIR}/scripts/record_trace.py" \ - --device "" --attach "" \ - --stop-file /tmp/stop-trace --output ~/Desktop/session.trace - ``` - For interactive sessions, just tell the user to press Ctrl+C when done. -4. **Signal stop** — when the user says they've finished exercising the app, `touch /tmp/stop-trace`. The script cleanly SIGINTs xctrace and waits up to 60s for finalisation. -5. **Analyse** the resulting trace (flow into the "Trace-driven improvement" workflow below). - -### Trace-driven improvement (Instruments `.trace` provided) -Trigger whenever the user's request references a `.trace` file. A target SwiftUI source file is **optional** — if given, cite specific lines; if not, recommend where to look based on view names and symbols the trace already reveals. - -Full reference: `references/trace-analysis.md`. Summary of the composition pattern: - -1. **Scope the analysis.** Ask yourself: does the user want the whole trace, or a slice? - - "focus on X / after X / between X and Y / during X" → **resolve to a window first** (see step 2). - - No scoping cue → analyse the whole trace. -2. **Resolve a window (only if the user scoped).** The parser exposes two discovery modes: - ```bash - # Find a log that marks the start/end of the region of interest: - python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace \ - --list-logs --log-message-contains "loaded feed" --log-limit 5 - # Or list os_signpost intervals (paired begin/end), filterable by name: - python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace \ - --list-signposts --signpost-name-contains "ImageDecode" - ``` - Both modes accept `--window START_MS:END_MS` to scope discovery. Pick the `time_ms` (for logs) or `start_ms`/`end_ms` (for signposts) that match the user's description. Build a window like `--window 10400:11700`. -3. **Run the main analysis** (with or without `--window`): - ```bash - python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace \ - --json-only --top 10 [--window START_MS:END_MS] - ``` -4. **Interpret with `references/trace-analysis.md`** — key diagnostics: - - `main_running_coverage_pct` inside each correlation (<25% = blocked; ≥75% = CPU-bound). - - `swiftui-causes.top_sources` reveals *why* updates keep happening — high-edge-count sources like `UserDefaultObserver.send()` or wide `EnvironmentWriter` entries are structural invalidation bugs. Fixing one often collapses many downstream hot views. -5. **When a specific view shows as expensive, ask who's invalidating it.** Use `--fanin-for ""` to get the ranked list of source nodes driving the updates. -6. **Optionally ground in source.** If the user pointed at a file, read it and match view names / user-code symbols against identifiers there. If not, recommend which files to open based on the view names SwiftUI reported. -7. **Return a prioritised plan.** Cite evidence (coverage %, hot symbol, overlapping view, log timestamp, cause-graph edges) and route each recommendation to a Topic Router reference. -8. Only edit code if the user asked for edits. - ### Topic Router Consult the reference file for each topic relevant to the current task: @@ -101,7 +51,6 @@ Consult the reference file for each topic relevant to the current task: | Layout | `references/layout-best-practices.md` | | Sheets and navigation | `references/sheet-navigation-patterns.md` | | ScrollView | `references/scroll-patterns.md` | -| Focus management | `references/focus-patterns.md` | | Animations (basics) | `references/animation-basics.md` | | Animations (transitions) | `references/animation-transitions.md` | | Animations (advanced) | `references/animation-advanced.md` | @@ -113,10 +62,7 @@ Consult the reference file for each topic relevant to the current task: | macOS scenes | `references/macos-scenes.md` | | macOS window styling | `references/macos-window-styling.md` | | macOS views | `references/macos-views.md` | -| Text patterns | `references/text-patterns.md` | | Deprecated API lookup | `references/latest-apis.md` | -| Instruments trace analysis | `references/trace-analysis.md` | -| Instruments trace recording | `references/trace-recording.md` | ## Correctness Checklist @@ -130,8 +76,6 @@ These are hard rules -- violations are always bugs: - [ ] `ForEach` uses stable identity (never `.indices` for dynamic content) - [ ] Constant number of views per `ForEach` element - [ ] `.animation(_:value:)` always includes the `value` parameter -- [ ] `@FocusState` properties are `private` -- [ ] No redundant `@FocusState` writes inside tap gesture handlers on `.focusable()` views - [ ] iOS 26+ APIs gated with `#available` and fallback provided - [ ] `import Charts` present in files using chart types @@ -151,12 +95,8 @@ These are hard rules -- violations are always bugs: - `references/charts-accessibility.md` -- Charts VoiceOver, Audio Graph, fallback strategies - `references/sheet-navigation-patterns.md` -- Sheets, NavigationSplitView, Inspector - `references/scroll-patterns.md` -- ScrollViewReader, programmatic scrolling -- `references/focus-patterns.md` -- Focus state, focusable views, focused values, default focus, common pitfalls - `references/image-optimization.md` -- AsyncImage, downsampling, caching - `references/liquid-glass.md` -- iOS 26+ Liquid Glass effects and fallback patterns - `references/macos-scenes.md` -- Settings, MenuBarExtra, WindowGroup, multi-window - `references/macos-window-styling.md` -- Toolbar styles, window sizing, Commands - `references/macos-views.md` -- HSplitView, Table, PasteButton, AppKit interop -- `references/text-patterns.md` -- Text initializer selection, verbatim vs localized -- `references/trace-analysis.md` -- Parse Instruments `.trace` files via `scripts/analyze_trace.py`; interpret main-thread coverage, high-severity SwiftUI updates, hitch narratives, and map findings back to source files -- `references/trace-recording.md` -- Record a new trace via `scripts/record_trace.py`: attach to a running app, launch one fresh, or capture a manually-stopped session; supports stop-file for agent-driven flows