返回 Skills
avdlee/swiftui-agent-skill· MIT 内容可用

swiftui-expert-skill

Use when writing, reviewing, or refactoring SwiftUI code for iOS or macOS, including state management and `@Observable` data flow, view composition and invalidation/performance, lists and `ForEach` identity, environment usage, localization, animations, Liquid Glass adoption, migrating soft-deprecated APIs, or Instruments `.trace` capture/analysis for hangs, hitches, CPU hotspots, or

安装

与 skills.sh 相同的 Command / Prompt 安装方式


name: swiftui-expert-skill description: Use when writing, reviewing, or refactoring SwiftUI code for iOS or macOS, including state management and @Observable data flow, view composition and invalidation/performance, lists and ForEach identity, environment usage, localization, animations, Liquid Glass adoption, migrating soft-deprecated APIs, or Instruments .trace capture/analysis for hangs, hitches, CPU hotspots, or excessive view updates.

SwiftUI Expert Skill

Operating Rules

  • Consult references/latest-apis.md at the start of every task to avoid deprecated APIs
  • Prefer native SwiftUI APIs over UIKit/AppKit bridging unless bridging is necessary
  • Focus on correctness and performance; do not enforce specific architectures (MVVM, VIPER, etc.)
  • Encourage separating business logic from views for testability without mandating how
  • Follow Apple's Human Interface Guidelines and API design patterns
  • Only adopt Liquid Glass when explicitly requested by the user (see references/liquid-glass.md)
  • Present performance optimizations as suggestions, not requirements
  • Use #available gating with sensible fallbacks for version-specific APIs

Task Workflow

Review existing SwiftUI code

  • Read the code under review and identify which topics apply
  • Flag deprecated APIs (compare against references/latest-apis.md)
  • Run the Topic Router below for each relevant topic
  • Validate #available gating and fallback paths for iOS 26+ features

Improve existing SwiftUI code

  • Audit current implementation against the Topic Router topics
  • Replace deprecated APIs with modern equivalents from references/latest-apis.md
  • Refactor hot paths to reduce unnecessary state updates
  • Extract complex view bodies into separate subviews
  • Suggest image downsampling when UIImage(data:) is encountered (optional optimization, see references/image-optimization.md)

Implement new SwiftUI feature

  • Design data flow first: identify owned vs injected state
  • Structure views for optimal diffing (extract subviews early)
  • Apply correct animation patterns (implicit vs explicit, transitions)
  • 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:
    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:
    python3 "${SKILL_DIR}/scripts/record_trace.py" \
        --device "<name|udid>" --attach "<AppName>" \
        --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:
    # Find a log that marks the start/end of the region of interest:
    python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace <path> \
        --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 <path> \
        --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):
    python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace <path> \
        --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 "<view name>" 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:

TopicReference
State managementreferences/state-management.md
View compositionreferences/view-structure.md
Performancereferences/performance-patterns.md
Lists and ForEachreferences/list-patterns.md
Layoutreferences/layout-best-practices.md
Sheets and navigationreferences/sheet-navigation-patterns.md
ScrollViewreferences/scroll-patterns.md
Focus managementreferences/focus-patterns.md
Animations (basics)references/animation-basics.md
Animations (transitions)references/animation-transitions.md
Animations (advanced)references/animation-advanced.md
Accessibilityreferences/accessibility-patterns.md
Swift Chartsreferences/charts.md
Charts accessibilityreferences/charts-accessibility.md
Image optimizationreferences/image-optimization.md
Liquid Glass (iOS 26+)references/liquid-glass.md
macOS scenesreferences/macos-scenes.md
macOS window stylingreferences/macos-window-styling.md
macOS viewsreferences/macos-views.md
Text patternsreferences/text-patterns.md
Localizationreferences/localization.md
Deprecated API lookupreferences/latest-apis.md
Handling soft-deprecated APIsreferences/soft-deprecation.md
Previewsreferences/previews.md
Instruments trace analysisreferences/trace-analysis.md
Instruments trace recordingreferences/trace-recording.md

Correctness Checklist

These are hard rules -- violations are always bugs:

  • @State properties are private
  • @Binding only where a child modifies parent state
  • Passed values never declared as @State or @StateObject (they ignore updates)
  • @StateObject for view-owned objects; @ObservedObject for injected
  • iOS 17+: @State with @Observable; @Bindable for injected observables needing bindings
  • ForEach uses stable identity (never .indices/\.offset; id outlives the view and isn't derived from mutable content)
  • Constant number of views per ForEach element; List rows are unary
  • No closures stored in custom @Environment/@FocusedValue keys
  • Custom @Entry default values are stable (no Model()/Date()/UUID() expressions)
  • .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+)
  • references/state-management.md -- Property wrappers, data flow, @Observable migration
  • references/view-structure.md -- View extraction, container patterns, @ViewBuilder
  • references/performance-patterns.md -- Hot-path optimization, update control, _logChanges()
  • references/list-patterns.md -- ForEach identity, Table (iOS 16+), inline filtering pitfalls
  • references/layout-best-practices.md -- Layout patterns, GeometryReader alternatives
  • references/accessibility-patterns.md -- VoiceOver, Dynamic Type, grouping, traits
  • references/animation-basics.md -- Implicit/explicit animations, timing, performance
  • references/animation-transitions.md -- View transitions, matchedGeometryEffect, Animatable
  • references/animation-advanced.md -- Phase/keyframe animations (iOS 17+), @Animatable macro (iOS 26+)
  • references/charts.md -- Swift Charts marks, axes, selection, styling, Chart3D (iOS 26+)
  • 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/previews.md -- #Preview macro, @Previewable (iOS 18+), preview traits, mock data patterns for self-contained previews
  • references/text-patterns.md -- Text initializer selection, verbatim vs localized
  • references/localization.md -- String Catalogs, #bundle for packages, LocalizedStringResource, locale-aware formatting, RTL layout, translator comments
  • references/soft-deprecation.md -- How to behave with soft-deprecated APIs (when to migrate, scoping rule, don't migrate during unrelated edits)
  • 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

附带文件

references/accessibility-patterns.md
# SwiftUI Accessibility Patterns Reference

## Table of Contents

- [Core Principle](#core-principle)
- [Dynamic Type and @ScaledMetric](#dynamic-type-and-scaledmetric)
- [Accessibility Traits](#accessibility-traits)
- [Decorative Images](#decorative-images)
- [Element Grouping](#element-grouping)
- [Custom Controls](#custom-controls)
- [Summary Checklist](#summary-checklist)

## Core Principle

Prefer `Button` over `onTapGesture` for tappable elements. `Button` provides VoiceOver support, focus handling, and proper traits for free.

## Dynamic Type and @ScaledMetric

System text styles scale with Dynamic Type automatically. Prefer built-in styles like `.largeTitle`, `.title`, `.title2`, `.title3`, `.headline`, `.subheadline`, `.body`, `.callout`, `.footnote`, `.caption`, and `.caption2` when they fit your UI:

```swift
VStack(alignment: .leading) {
    Text("Inbox")
        .font(.title2)
    Text("3 unread messages")
        .font(.body)
    Text("Updated just now")
        .font(.caption)
}
```

For custom fonts, use a Dynamic Type-aware font initializer so the text still follows the user's preferred content size:

```swift
VStack(alignment: .leading) {
    Text("Article")
        .font(.custom("SourceSerif4-Semibold", size: 28, relativeTo: .title2))
    Text("Body copy")
        .font(.custom("SourceSerif4-Regular", size: 17))
}
```

`Font.custom(_:size:relativeTo:)` lets you match a specific text style. `Font.custom(_:size:)` scales relative to the body style. Avoid fixed-size custom fonts for primary content that should respond to Dynamic Type.

For non-text numeric values like padding, spacing, and image sizes, use `@ScaledMetric`:

```swift
struct ProfileHeader: View {
    @ScaledMetric private var avatarSize = 60.0
    @ScaledMetric private var spacing = 12.0

    var body: some View {
        HStack(spacing: spacing) {
            Image("avatar")
                .resizable()
                .frame(width: avatarSize, height: avatarSize)
            Text("Username")
        }
    }
}
```

Specify a `relativeTo` text style when the value should track a specific Dynamic Type style, including for images or icons that should stay proportional to nearby text:

```swift
struct StatusRow: View {
    @ScaledMetric(relativeTo: .body) private var iconSize = 18.0

    var body: some View {
        HStack(spacing: 8) {
            Image(systemName: "checkmark.circle.fill")
                .font(.system(size: iconSize))
            Text("Synced")
                .font(.custom("AvenirNext-Regular", size: 17, relativeTo: .body))
        }
    }
}
```

## Accessibility Traits

Use `accessibilityAddTraits` and `accessibilityRemoveTraits` for state-driven traits:

```swift
Text(item.title)
    .accessibilityAddTraits(item.isSelected ? [.isSelected, .isButton] : .isButton)
```

Use `.disabled(true)` to make VoiceOver announce "Dimmed" for non-interactive elements.

## Decorative Images

Use `Image(decorative:bundle:)` when an asset image is purely visual and should not appear in the accessibility tree.

```swift
Image(decorative: "confetti")
```

This is appropriate for backgrounds, flourishes, and icons that do not add meaning beyond nearby text.

If the image conveys information, keep it accessible and provide a clear label:

```swift
Image("receipt")
    .accessibilityLabel("Receipt")
```

For non-asset images, such as SF Symbols, hide decorative content with `accessibilityHidden(true)` instead:

```swift
Image(systemName: "sparkles")
    .accessibilityHidden(true)
```

## Element Grouping

### .combine -- Auto-join child labels

```swift
HStack {
    Image(systemName: "star.fill")
    Text("Favorites")
    Text("(\(count))")
}
.accessibilityElement(children: .combine)
```

VoiceOver reads all child labels as one element, separated by commas.

### .ignore -- Manual label for container

```swift
HStack {
    Text(item.name)
    Spacer()
    Text(item.price)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(item.name), \(item.price)")
```

### .contain -- Semantic grouping

```swift
HStack {
    ForEach(tabs) { tab in
        TabButton(tab: tab)
    }
}
.accessibilityElement(children: .contain)
.accessibilityLabel("Tab bar")
```

VoiceOver announces the container name when focus enters/exits.

## Custom Controls

### Adjustable controls (increment/decrement)

```swift
PageControl(selectedIndex: $selectedIndex, pageCount: pageCount)
    .accessibilityElement()
    .accessibilityValue("Page \(selectedIndex + 1) of \(pageCount)")
    .accessibilityAdjustableAction { direction in
        switch direction {
        case .increment:
            guard selectedIndex < pageCount - 1 else { break }
            selectedIndex += 1
        case .decrement:
            guard selectedIndex > 0 else { break }
            selectedIndex -= 1
        @unknown default:
            break
        }
    }
```

### Representing custom views as native controls

When a custom view should behave like a native control for accessibility:

```swift
HStack {
    Text(label)
    Toggle("", isOn: $isOn)
}
.accessibilityRepresentation {
    Toggle(label, isOn: $isOn)
}
```

### Label-content pairing

```swift
@Namespace private var ns

HStack {
    Text("Volume")
        .accessibilityLabeledPair(role: .label, id: "volume", in: ns)
    Slider(value: $volume)
        .accessibilityLabeledPair(role: .content, id: "volume", in: ns)
}
```

## Summary Checklist

- [ ] Use `Button` instead of `onTapGesture` for tappable elements
- [ ] Use built-in text styles or Dynamic Type-aware custom fonts for text
- [ ] Use `@ScaledMetric` for custom values that should scale with Dynamic Type
- [ ] Mark purely decorative images as decorative or hidden from accessibility
- [ ] Group related elements with `accessibilityElement(children:)`
- [ ] Provide `accessibilityLabel` when default labels are unclear
- [ ] Use `accessibilityRepresentation` for custom controls
- [ ] Use `accessibilityAdjustableAction` for increment/decrement controls
- [ ] Ensure navigation flow is logical when using VoiceOver grouping
references/animation-advanced.md
# SwiftUI Advanced Animations

Transactions, phase animations (iOS 17+), keyframe animations (iOS 17+), completion handlers (iOS 17+), and `@Animatable` macro (iOS 26+).

## Table of Contents
- [Transactions](#transactions)
- [Phase Animations (iOS 17+)](#phase-animations-ios-17)
- [Keyframe Animations (iOS 17+)](#keyframe-animations-ios-17)
- [Animation Completion Handlers (iOS 17+)](#animation-completion-handlers-ios-17)
- [@Animatable Macro (iOS 26+)](#animatable-macro-ios-26)

---

## Transactions

The underlying mechanism for all animations in SwiftUI.

### Basic Usage

```swift
// withAnimation is shorthand for withTransaction
withAnimation(.default) { flag.toggle() }

// Equivalent explicit transaction
var transaction = Transaction(animation: .default)
withTransaction(transaction) { flag.toggle() }
```

### The .transaction Modifier

```swift
Rectangle()
    .frame(width: flag ? 100 : 50, height: 50)
    .transaction { t in
        t.animation = .default
    }
```

**Note:** This behaves like the deprecated `.animation(_:)` without value parameter - it animates on every state change.

### Animation Precedence

**Implicit animations override explicit animations** (later in view tree wins).

```swift
Button("Tap") {
    withAnimation(.linear) { flag.toggle() }
}
.animation(.bouncy, value: flag)  // .bouncy wins!
```

### Disabling Animations

```swift
// Prevent implicit animations from overriding
.transaction { t in
    t.disablesAnimations = true
}

// Remove animation entirely
.transaction { $0.animation = nil }
```

### Custom Transaction Keys (iOS 17+)

Pass metadata through transactions.

```swift
struct ChangeSourceKey: TransactionKey {
    static let defaultValue: String = "unknown"
}

extension Transaction {
    var changeSource: String {
        get { self[ChangeSourceKey.self] }
        set { self[ChangeSourceKey.self] = newValue }
    }
}

// Set source
var transaction = Transaction(animation: .default)
transaction.changeSource = "server"
withTransaction(transaction) { flag.toggle() }

// Read in view tree
.transaction { t in
    if t.changeSource == "server" {
        t.animation = .smooth
    } else {
        t.animation = .bouncy
    }
}
```

---

## Phase Animations (iOS 17+)

Cycle through discrete phases automatically. Each phase change is a separate animation.

### Basic Usage

```swift
// GOOD - triggered phase animation
Button("Shake") { trigger += 1 }
    .phaseAnimator(
        [0.0, -10.0, 10.0, -5.0, 5.0, 0.0],
        trigger: trigger
    ) { content, offset in
        content.offset(x: offset)
    }

// Infinite loop (no trigger)
Circle()
    .phaseAnimator([1.0, 1.2, 1.0]) { content, scale in
        content.scaleEffect(scale)
    }
```

### Enum Phases (Recommended for Clarity)

```swift
// GOOD - enum phases are self-documenting
enum BouncePhase: CaseIterable {
    case initial, up, down, settle

    var scale: CGFloat {
        switch self {
        case .initial: 1.0
        case .up: 1.2
        case .down: 0.9
        case .settle: 1.0
        }
    }
}

Circle()
    .phaseAnimator(BouncePhase.allCases, trigger: trigger) { content, phase in
        content.scaleEffect(phase.scale)
    }
```

### Custom Timing Per Phase

```swift
.phaseAnimator([0, -20, 20], trigger: trigger) { content, offset in
    content.offset(x: offset)
} animation: { phase in
    switch phase {
    case -20: .bouncy
    case 20: .linear
    default: .smooth
    }
}
```

### Good vs Bad

```swift
// GOOD - use phaseAnimator for multi-step sequences
.phaseAnimator([0, -10, 10, 0], trigger: trigger) { content, offset in
    content.offset(x: offset)
}

// BAD - manual DispatchQueue sequencing
Button("Animate") {
    withAnimation(.easeOut(duration: 0.1)) { offset = -10 }
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
        withAnimation { offset = 10 }
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
        withAnimation { offset = 0 }
    }
}
```

---

## Keyframe Animations (iOS 17+)

Precise timing control with exact values at specific times.

### Basic Usage

```swift
Button("Bounce") { trigger += 1 }
    .keyframeAnimator(
        initialValue: AnimationValues(),
        trigger: trigger
    ) { content, value in
        content
            .scaleEffect(value.scale)
            .offset(y: value.verticalOffset)
    } keyframes: { _ in
        KeyframeTrack(\.scale) {
            SpringKeyframe(1.2, duration: 0.15)
            SpringKeyframe(0.9, duration: 0.1)
            SpringKeyframe(1.0, duration: 0.15)
        }
        KeyframeTrack(\.verticalOffset) {
            LinearKeyframe(-20, duration: 0.15)
            LinearKeyframe(0, duration: 0.25)
        }
    }

struct AnimationValues {
    var scale: CGFloat = 1.0
    var verticalOffset: CGFloat = 0
}
```

### Keyframe Types

| Type | Behavior |
|------|----------|
| `CubicKeyframe` | Smooth interpolation |
| `LinearKeyframe` | Straight-line interpolation |
| `SpringKeyframe` | Spring physics |
| `MoveKeyframe` | Instant jump (no interpolation) |

### Multiple Synchronized Tracks

Tracks run **in parallel**, each animating one property.

```swift
// GOOD - bell shake with synchronized rotation and scale
struct BellAnimation {
    var rotation: Double = 0
    var scale: CGFloat = 1.0
}

Image(systemName: "bell.fill")
    .keyframeAnimator(
        initialValue: BellAnimation(),
        trigger: trigger
    ) { content, value in
        content
            .rotationEffect(.degrees(value.rotation))
            .scaleEffect(value.scale)
    } keyframes: { _ in
        KeyframeTrack(\.rotation) {
            CubicKeyframe(15, duration: 0.1)
            CubicKeyframe(-15, duration: 0.1)
            CubicKeyframe(10, duration: 0.1)
            CubicKeyframe(-10, duration: 0.1)
            CubicKeyframe(0, duration: 0.1)
        }
        KeyframeTrack(\.scale) {
            CubicKeyframe(1.1, duration: 0.25)
            CubicKeyframe(1.0, duration: 0.25)
        }
    }

// BAD - manual timer-based animation
Image(systemName: "bell.fill")
    .onTapGesture {
        withAnimation(.easeOut(duration: 0.1)) { rotation = 15 }
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            withAnimation { rotation = -15 }
        }
        // ... more manual timing - error prone
    }
```

### KeyframeTimeline (iOS 17+)

Query animation values directly for testing or non-SwiftUI use.

```swift
let timeline = KeyframeTimeline(initialValue: AnimationValues()) {
    KeyframeTrack(\.scale) {
        CubicKeyframe(1.2, duration: 0.25)
        CubicKeyframe(1.0, duration: 0.25)
    }
}

let midpoint = timeline.value(time: 0.25)
print(midpoint.scale)  // Value at 0.25 seconds
```

---

## Animation Completion Handlers (iOS 17+)

Execute code when animations finish.

### With withAnimation

```swift
// GOOD - completion with withAnimation
Button("Animate") {
    withAnimation(.spring) {
        isExpanded.toggle()
    } completion: {
        showNextStep = true
    }
}
```

### With Transaction (For Reexecution)

```swift
// GOOD - completion fires on every trigger change
Circle()
    .scaleEffect(bounceCount % 2 == 0 ? 1.0 : 1.2)
    .transaction(value: bounceCount) { transaction in
        transaction.animation = .spring
        transaction.addAnimationCompletion {
            message = "Bounce \(bounceCount) complete"
        }
    }

// BAD - completion only fires ONCE (no value parameter)
Circle()
    .scaleEffect(bounceCount % 2 == 0 ? 1.0 : 1.2)
    .animation(.spring, value: bounceCount)
    .transaction { transaction in  // No value!
        transaction.addAnimationCompletion {
            completionCount += 1  // Only fires once, ever
        }
    }
```

---

## @Animatable Macro (iOS 26+)

The `@Animatable` macro auto-synthesizes `animatableData` from all animatable stored properties, eliminating verbose manual conformance. Use `@AnimatableIgnored` to exclude properties that should not animate.

### Before (Manual)

```swift
struct Wedge: Shape {
    var startAngle: Angle
    var endAngle: Angle
    var drawClockwise: Bool

    var animatableData: AnimatablePair<Double, Double> {
        get { AnimatablePair(startAngle.radians, endAngle.radians) }
        set {
            startAngle = .radians(newValue.first)
            endAngle = .radians(newValue.second)
        }
    }

    func path(in rect: CGRect) -> Path { /* ... */ }
}
```

### After (@Animatable)

```swift
@Animatable
struct Wedge: Shape {
    var startAngle: Angle
    var endAngle: Angle
    @AnimatableIgnored var drawClockwise: Bool

    func path(in rect: CGRect) -> Path { /* ... */ }
}
```

### When to Use
- **Prefer `@Animatable`** for any custom `Shape`, `AnimatableModifier`, or type conforming to `Animatable` with multiple properties
- **Use `@AnimatableIgnored`** for properties that control behavior but should not interpolate (e.g., directions, flags, identifiers)
- The macro works with any type conforming to `Animatable`, not just `Shape`

> Source: "What's new in SwiftUI" (WWDC25, session 256)

### When to Implement `animatableData` Manually

Reach for an explicit `animatableData` (instead of the macro) when the interpolated value needs custom logic that doesn't map 1:1 to a stored property — normalization, clamping, or driving a derived value. For a deployment target of iOS 26+, use `AnimatableValues`; for earlier targets, use `AnimatablePair`.

```swift
// iOS 26+: keep phase in 0..<2π and clamp amplitude during interpolation
struct WaveShape: Shape {
    var amplitude: CGFloat
    var phase: CGFloat
    var maxAmplitude: CGFloat

    var animatableData: AnimatableValues<CGFloat, CGFloat> {
        get { AnimatableValues(amplitude, phase) }
        set {
            amplitude = min(max(newValue.value.0, 0), maxAmplitude)
            phase = newValue.value.1.truncatingRemainder(dividingBy: 2 * .pi)
        }
    }

    func path(in rect: CGRect) -> Path { /* ... */ }
}
```

On earlier deployment targets, the same logic uses `AnimatablePair` with `newValue.first` / `newValue.second`.

---

## Quick Reference

### Transactions (All iOS versions)
- `withTransaction` is the explicit form of `withAnimation`
- Implicit animations override explicit (later in view tree wins)
- Use `disablesAnimations` to prevent override
- Use `.transaction { $0.animation = nil }` to remove animation

### Custom Transaction Keys (iOS 17+)
- Pass metadata through animation system via `TransactionKey`

### Phase Animations (iOS 17+)
- Use for multi-step sequences returning to start
- Prefer enum phases for clarity
- Each phase change is a separate animation
- Use `trigger` parameter for one-shot animations

### Keyframe Animations (iOS 17+)
- Use for precise timing control
- Tracks run in parallel
- Use `KeyframeTimeline` for testing/advanced use
- Prefer over manual DispatchQueue timing

### Completion Handlers (iOS 17+)
- Use `withAnimation(.animation) { } completion: { }` for one-shot completion handlers
- Use `.transaction(value:)` for handlers that should refire on every value change
- Without `value:` parameter, completion only fires once

### @Animatable Macro (iOS 26+)
- Use `@Animatable` to auto-synthesize `animatableData` from stored properties
- Use `@AnimatableIgnored` to exclude non-animatable properties
- Replaces verbose manual `animatableData` getters/setters
references/animation-basics.md
# SwiftUI Animation Basics

Core animation concepts, implicit vs explicit animations, timing curves, and performance patterns.

## Table of Contents
- [Core Concepts](#core-concepts)
- [Implicit Animations](#implicit-animations)
- [Explicit Animations](#explicit-animations)
- [Animation Placement](#animation-placement)
- [Selective Animation](#selective-animation)
- [Timing Curves](#timing-curves)
- [Animation Performance](#animation-performance)
- [Disabling Animations](#disabling-animations)
- [Debugging](#debugging)

---

## Core Concepts

State changes trigger view updates. SwiftUI provides mechanisms to animate these changes.

**Animation Process:**
1. State change triggers view tree re-evaluation
2. SwiftUI compares new tree to current render tree
3. Animatable properties are identified and interpolated (~60 fps)

**Key Characteristics:**
- Animations are additive and cancelable
- Always start from current render tree state
- Blend smoothly when interrupted

---

## Implicit Animations

Use `.animation(_:value:)` to animate when a specific value changes.

```swift
// GOOD - uses value parameter
Rectangle()
    .frame(width: isExpanded ? 200 : 100, height: 50)
    .animation(.spring, value: isExpanded)
    .onTapGesture { isExpanded.toggle() }

// BAD - deprecated, animates all changes unexpectedly
Rectangle()
    .frame(width: isExpanded ? 200 : 100, height: 50)
    .animation(.spring)  // Deprecated!
```

---

## Explicit Animations

Use `withAnimation` for event-driven state changes.

```swift
// GOOD - explicit animation
Button("Toggle") {
    withAnimation(.spring) {
        isExpanded.toggle()
    }
}

// BAD - no animation context
Button("Toggle") {
    isExpanded.toggle()  // Abrupt change
}
```

**When to use which:**
- **Implicit**: Animations tied to specific value changes, precise view tree scope
- **Explicit**: Event-driven animations (button taps, gestures)

---

## Animation Placement

Place animation modifiers after the properties they should animate.

```swift
// GOOD - animation after properties
Rectangle()
    .frame(width: isExpanded ? 200 : 100, height: 50)
    .foregroundStyle(isExpanded ? .blue : .red)
    .animation(.default, value: isExpanded)  // Animates both

// BAD - animation before properties
Rectangle()
    .animation(.default, value: isExpanded)  // Too early!
    .frame(width: isExpanded ? 200 : 100, height: 50)
```

---

## Selective Animation

Animate only specific properties using multiple animation modifiers or scoped animations.

```swift
// GOOD - selective animation
Rectangle()
    .frame(width: isExpanded ? 200 : 100, height: 50)
    .animation(.spring, value: isExpanded)  // Animate size
    .foregroundStyle(isExpanded ? .blue : .red)
    .animation(nil, value: isExpanded)  // Don't animate color

// iOS 17+ scoped animation
Rectangle()
    .foregroundStyle(isExpanded ? .blue : .red)  // Not animated
    .animation(.spring) {
        $0.frame(width: isExpanded ? 200 : 100, height: 50)  // Animated
    }
```

---

## Timing Curves

### Built-in Curves

| Curve | Use Case |
|-------|----------|
| `.spring` | Interactive elements, most UI |
| `.easeInOut` | Appearance changes |
| `.bouncy` | Playful feedback (iOS 17+) |
| `.linear` | Progress indicators only |

### Modifiers

```swift
.animation(.default.speed(2.0), value: flag)  // 2x faster
.animation(.default.delay(0.5), value: flag)  // Delayed start
.animation(.default.repeatCount(3, autoreverses: true), value: flag)
```

### Good vs Bad Timing

```swift
// GOOD - appropriate timing for interaction type
Button("Tap") {
    withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
        isActive.toggle()
    }
}
.scaleEffect(isActive ? 0.95 : 1.0)

// BAD - too slow for button feedback
Button("Tap") {
    withAnimation(.easeInOut(duration: 1.0)) {  // Way too slow!
        isActive.toggle()
    }
}

// BAD - linear feels robotic
Rectangle()
    .animation(.linear(duration: 0.5), value: isActive)  // Mechanical
```

---

## Animation Performance

### Prefer Transforms Over Layout

```swift
// GOOD - GPU accelerated transforms
Rectangle()
    .frame(width: 100, height: 100)
    .scaleEffect(isActive ? 1.5 : 1.0)  // Fast
    .offset(x: isActive ? 50 : 0)        // Fast
    .rotationEffect(.degrees(isActive ? 45 : 0))  // Fast
    .animation(.spring, value: isActive)

// BAD - layout changes are expensive
Rectangle()
    .frame(width: isActive ? 150 : 100, height: isActive ? 150 : 100)  // Expensive
    .padding(isActive ? 50 : 0)  // Expensive
```

### Narrow Animation Scope

```swift
// GOOD - animation scoped to specific subview
VStack {
    HeaderView()  // Not affected
    ExpandableContent(isExpanded: isExpanded)
        .animation(.spring, value: isExpanded)  // Only this
    FooterView()  // Not affected
}

// BAD - animation at root
VStack {
    HeaderView()
    ExpandableContent(isExpanded: isExpanded)
    FooterView()
}
.animation(.spring, value: isExpanded)  // Animates everything
```

### Avoid Animation in Hot Paths

```swift
// GOOD - gate by threshold
.onPreferenceChange(ScrollOffsetKey.self) { offset in
    let shouldShow = offset.y < -50
    if shouldShow != showTitle {  // Only when crossing threshold
        withAnimation(.easeOut(duration: 0.2)) {
            showTitle = shouldShow
        }
    }
}

// BAD - animating every scroll change
.onPreferenceChange(ScrollOffsetKey.self) { offset in
    withAnimation {  // Fires constantly!
        self.offset = offset.y
    }
}
```

---

## Disabling Animations

```swift
// GOOD - disable with transaction
Text("Count: \(count)")
    .transaction { $0.animation = nil }

// GOOD - disable from parent context
DataView()
    .transaction { $0.disablesAnimations = true }

// BAD - hacky zero duration
Text("Count: \(count)")
    .animation(.linear(duration: 0), value: count)  // Hacky
```

---

## Debugging

```swift
// Slow down for inspection
#if DEBUG
.animation(.linear(duration: 3.0).speed(0.2), value: isExpanded)
#else
.animation(.spring, value: isExpanded)
#endif

// Debug modifier to log values
struct AnimationDebugModifier: ViewModifier, Animatable {
    var value: Double
    var animatableData: Double {
        get { value }
        set {
            value = newValue
            print("Animation: \(newValue)")
        }
    }
    func body(content: Content) -> some View {
        content.opacity(value)
    }
}
```

---

## Quick Reference

### Do
- Use `.animation(_:value:)` with value parameter
- Use `withAnimation` for event-driven animations
- Prefer transforms over layout changes
- Scope animations narrowly
- Choose appropriate timing curves

### Don't
- Use deprecated `.animation(_:)` without value
- Animate layout properties in hot paths
- Apply broad animations at root level
- Use linear timing for UI (feels robotic)
- Animate on every frame in scroll handlers
references/animation-transitions.md
# SwiftUI Transitions

Transitions for view insertion/removal, custom transitions, and the Animatable protocol.

## Table of Contents
- [Property Animations vs Transitions](#property-animations-vs-transitions)
- [Basic Transitions](#basic-transitions)
- [Asymmetric Transitions](#asymmetric-transitions)
- [Custom Transitions](#custom-transitions)
- [Identity and Transitions](#identity-and-transitions)
- [The Animatable Protocol](#the-animatable-protocol)

---

## Property Animations vs Transitions

**Property animations**: Interpolate values on views that exist before AND after state change.

**Transitions**: Animate views being inserted or removed from the render tree.

```swift
// Property animation - same view, different properties
Rectangle()
    .frame(width: isExpanded ? 200 : 100, height: 50)
    .animation(.spring, value: isExpanded)

// Transition - view inserted/removed
if showDetail {
    DetailView()
        .transition(.scale)
}
```

---

## Basic Transitions

### Critical: Transitions Require Animation Context

```swift
// GOOD - animation outside conditional
VStack {
    Button("Toggle") { showDetail.toggle() }
    if showDetail {
        DetailView()
            .transition(.slide)
    }
}
.animation(.spring, value: showDetail)

// GOOD - explicit animation
Button("Toggle") {
    withAnimation(.spring) {
        showDetail.toggle()
    }
}
if showDetail {
    DetailView()
        .transition(.scale.combined(with: .opacity))
}

// BAD - animation inside conditional (removed with view!)
if showDetail {
    DetailView()
        .transition(.slide)
        .animation(.spring, value: showDetail)  // Won't work on removal!
}

// BAD - no animation context
Button("Toggle") {
    showDetail.toggle()  // No animation
}
if showDetail {
    DetailView()
        .transition(.slide)  // Ignored - just appears/disappears
}
```

### Built-in Transitions

| Transition | Effect |
|------------|--------|
| `.opacity` | Fade in/out (default) |
| `.scale` | Scale up/down |
| `.slide` | Slide from leading edge |
| `.move(edge:)` | Move from specific edge |
| `.offset(x:y:)` | Move by offset amount |

### Combining Transitions

```swift
// Parallel - both simultaneously
.transition(.slide.combined(with: .opacity))

// Chained
.transition(.scale.combined(with: .opacity).combined(with: .offset(y: 20)))
```

---

## Asymmetric Transitions

Different animations for insertion vs removal.

```swift
// GOOD - different animations for insert/remove
if showCard {
    CardView()
        .transition(
            .asymmetric(
                insertion: .scale.combined(with: .opacity),
                removal: .move(edge: .bottom).combined(with: .opacity)
            )
        )
}

// BAD - same transition when different behaviors needed
if showCard {
    CardView()
        .transition(.slide)  // Same both ways - may feel awkward
}
```

---

## Custom Transitions

### Pre-iOS 17

```swift
struct BlurModifier: ViewModifier {
    var radius: CGFloat
    func body(content: Content) -> some View {
        content.blur(radius: radius)
    }
}

extension AnyTransition {
    static func blur(radius: CGFloat) -> AnyTransition {
        .modifier(
            active: BlurModifier(radius: radius),
            identity: BlurModifier(radius: 0)
        )
    }
}

// Usage
.transition(.blur(radius: 10))
```

### iOS 17+ (Transition Protocol)

```swift
struct BlurTransition: Transition {
    var radius: CGFloat

    func body(content: Content, phase: TransitionPhase) -> some View {
        content
            .blur(radius: phase.isIdentity ? 0 : radius)
            .opacity(phase.isIdentity ? 1 : 0)
    }
}

// Usage
.transition(BlurTransition(radius: 10))
```

### Good vs Bad Custom Transitions

```swift
// GOOD - reusable transition
if showContent {
    ContentView()
        .transition(BlurTransition(radius: 10))
}

// BAD - inline logic (won't animate on removal!)
if showContent {
    ContentView()
        .blur(radius: showContent ? 0 : 10)  // Not a transition
        .opacity(showContent ? 1 : 0)
}
```

---

## Identity and Transitions

View identity changes trigger transitions, not property animations.

```swift
// Triggers transition - different branches have different identities
if isExpanded {
    Rectangle().frame(width: 200, height: 50)
} else {
    Rectangle().frame(width: 100, height: 50)
}

// Triggers transition - .id() changes identity
Rectangle()
    .id(flag)  // Different identity when flag changes
    .transition(.scale)

// Property animation - same view, same identity
Rectangle()
    .frame(width: isExpanded ? 200 : 100, height: 50)
    .animation(.spring, value: isExpanded)
```

---

## The Animatable Protocol

Enables custom property interpolation during animations.

### Protocol Definition

```swift
protocol Animatable {
    associatedtype AnimatableData: VectorArithmetic
    var animatableData: AnimatableData { get set }
}
```

### Basic Implementation

```swift
// GOOD - explicit animatableData
struct ShakeModifier: ViewModifier, Animatable {
    var shakeCount: Double

    var animatableData: Double {
        get { shakeCount }
        set { shakeCount = newValue }
    }

    func body(content: Content) -> some View {
        content.offset(x: sin(shakeCount * .pi * 2) * 10)
    }
}

extension View {
    func shake(count: Int) -> some View {
        modifier(ShakeModifier(shakeCount: Double(count)))
    }
}

// Usage
Button("Shake") { shakeCount += 3 }
    .shake(count: shakeCount)
    .animation(.default, value: shakeCount)

// BAD - missing animatableData (silent failure!)
struct BadShakeModifier: ViewModifier {
    var shakeCount: Double
    // Missing animatableData! Uses EmptyAnimatableData

    func body(content: Content) -> some View {
        content.offset(x: sin(shakeCount * .pi * 2) * 10)
    }
}
// Animation jumps to final value instead of interpolating
```

### Multiple Properties with AnimatablePair

```swift
// GOOD - AnimatablePair for two properties
struct ComplexModifier: ViewModifier, Animatable {
    var scale: CGFloat
    var rotation: Double

    var animatableData: AnimatablePair<CGFloat, Double> {
        get { AnimatablePair(scale, rotation) }
        set {
            scale = newValue.first
            rotation = newValue.second
        }
    }

    func body(content: Content) -> some View {
        content
            .scaleEffect(scale)
            .rotationEffect(.degrees(rotation))
    }
}

// GOOD - nested AnimatablePair for 3+ properties
struct ThreePropertyModifier: ViewModifier, Animatable {
    var x: CGFloat
    var y: CGFloat
    var rotation: Double

    var animatableData: AnimatablePair<AnimatablePair<CGFloat, CGFloat>, Double> {
        get { AnimatablePair(AnimatablePair(x, y), rotation) }
        set {
            x = newValue.first.first
            y = newValue.first.second
            rotation = newValue.second
        }
    }

    func body(content: Content) -> some View {
        content
            .offset(x: x, y: y)
            .rotationEffect(.degrees(rotation))
    }
}
```

---

## Quick Reference

### Do
- Place transitions outside conditional structures
- Use `withAnimation` or `.animation` outside the `if`
- Implement `animatableData` explicitly for custom Animatable
- Use `AnimatablePair` for multiple animated properties
- Use asymmetric transitions when insert/remove need different effects

### Don't
- Put animation modifiers inside conditionals for transitions
- Forget `animatableData` implementation (silent failure)
- Use inline blur/opacity instead of proper transitions
- Expect property animation when view identity changes
references/charts-accessibility.md
# Swift Charts Accessibility, Fallback, and Resources

## Table of Contents

- [Accessibility](#accessibility)
  - [Meaningful Labels](#meaningful-labels)
  - [Custom Audio Graphs](#custom-audio-graphs)
- [Composite Example](#composite-example)
- [Fallback Strategies](#fallback-strategies)
  - [Version Breakdown](#version-breakdown)
- [WWDC Sessions](#wwdc-sessions)
- [Summary Checklist](#summary-checklist)

---

## Accessibility

Swift Charts provides built-in accessibility support. VoiceOver users get three rotor actions automatically:

- **Describe Chart** — overview of axes and data series
- **Audio Graph** — sonification where pitch represents data values
- **Chart Detail** — interactive mode for exploring individual data points

### Meaningful Labels

**Always** use clear, descriptive strings in `.value(_, _)` calls. These labels are read by VoiceOver and used in the Audio Graph.

```swift
// Good — descriptive labels
LineMark(
    x: .value("Date", entry.date),
    y: .value("Daily Steps", entry.count)
)

// Bad — generic labels
LineMark(
    x: .value("X", entry.date),
    y: .value("Y", entry.count)
)
```

### Custom Audio Graphs

For advanced accessibility, conform your chart view to `AXChartDescriptorRepresentable` and implement `makeChartDescriptor()`. Attach it with `.accessibilityChartDescriptor(self)`.

```swift
struct StepsChart: View, AXChartDescriptorRepresentable {
    let steps: [DailySteps]

    var body: some View {
        Chart(steps) { day in
            LineMark(x: .value("Date", day.date), y: .value("Steps", day.count))
        }
        .accessibilityChartDescriptor(self)
    }

    func makeChartDescriptor() -> AXChartDescriptor {
        guard let first = steps.first, let last = steps.last else {
            return AXChartDescriptor(title: "Daily Step Count", summary: nil,
                xAxis: AXNumericDataAxisDescriptor(title: "Date", range: 0...1, gridlinePositions: []) { "\($0)" },
                yAxis: AXNumericDataAxisDescriptor(title: "Steps", range: 0...1, gridlinePositions: []) { "\($0)" },
                additionalAxes: [], series: [])
        }
        let xAxis = AXDateDataAxisDescriptor(
            title: "Date", range: first.date...last.date, gridlinePositions: [])
        let yAxis = AXNumericDataAxisDescriptor(
            title: "Steps", range: 0...Double(steps.map(\.count).max() ?? 0),
            gridlinePositions: []) { "\(Int($0)) steps" }
        let series = AXDataSeriesDescriptor(
            name: "Daily Steps", isContinuous: true,
            dataPoints: steps.map { .init(x: $0.date, y: Double($0.count)) })
        return AXChartDescriptor(title: "Daily Step Count", summary: nil,
            xAxis: xAxis, yAxis: yAxis, additionalAxes: [], series: [series])
    }
}
```

## Composite Example

A scrollable bar chart with range selection combining multiple iOS 17+ APIs:

```swift
@State private var selectedRange: ClosedRange<Int>?

Chart(weeklyRevenue) { week in
    BarMark(x: .value("Week", week.index), y: .value("Revenue", week.revenue))
        .foregroundStyle(by: .value("Region", week.region))
}
.chartScrollableAxes(.horizontal)
.chartXVisibleDomain(length: 8)
.chartXSelection(range: $selectedRange)
.chartXAxis {
    AxisMarks(values: .stride(by: 1)) {
        AxisGridLine()
        AxisValueLabel { Text("W\($0.as(Int.self) ?? 0)") }
    }
}
```

## Fallback Strategies

Gate advanced APIs with `#available` and provide a fallback chart without the gated features. Because chart modifiers like `.chartXSelection` change the return type, you must duplicate the entire `Chart` — you cannot conditionally apply the modifier:

### Version Breakdown

- iOS 16+: `Chart`, custom axes, scales, `BarMark`, `LineMark`, `AreaMark`, `PointMark`, `RectangleMark`, `RuleMark`, `ChartProxy`, `chartOverlay`, `chartBackground`
- iOS 17+: `SectorMark`, `chartXSelection`, `chartYSelection`, `chartAngleSelection`, `chartScrollableAxes`, visible-domain scrolling APIs, `chartGesture`
- iOS 18+: `AreaPlot`, `BarPlot`, `LinePlot`, `PointPlot`, `RectanglePlot`, `RulePlot`, `SectorPlot`, function plotting
- iOS 26+: `Chart3D`, `SurfacePlot`, Z-axis marks, 3D camera and pose APIs

## WWDC Sessions

- [Hello Swift Charts](https://developer.apple.com/videos/play/wwdc2022/10136/) (WWDC 2022) — introduction to the framework
- [Swift Charts: Raise the bar](https://developer.apple.com/videos/play/wwdc2022/10137/) (WWDC 2022) — marks, composition, customization
- [Design an effective chart](https://developer.apple.com/videos/play/wwdc2022/110340/) (WWDC 2022) — chart design principles
- [Design app experiences with charts](https://developer.apple.com/videos/play/wwdc2022/110342/) (WWDC 2022) — integrating charts into app UX
- [Explore pie charts and interactivity in Swift Charts](https://developer.apple.com/videos/play/wwdc2023/10037/) (WWDC 2023) — SectorMark, selection, scrolling
- [Swift Charts: Vectorized and function plots](https://developer.apple.com/videos/play/wwdc2024/10155/) (WWDC 2024) — LinePlot, AreaPlot, function plotting
- [Bring Swift Charts to the third dimension](https://developer.apple.com/videos/play/wwdc2025/313/) (WWDC 2025) — Chart3D, SurfacePlot, 3D marks

## Summary Checklist

- [ ] `import Charts` is present in files using chart types
- [ ] Deployment target matches the APIs used (`Chart` on iOS 16+, selection and `SectorMark` on iOS 17+, plot types on iOS 18+, `Chart3D` on iOS 26+)
- [ ] Chart data models use `Identifiable` (or `Chart(data, id:)` is provided)
- [ ] All chart families are represented with the correct mark type
- [ ] Axes use `AxisMarks` when default ticks are too dense or unclear
- [ ] `chartXScale` or `chartYScale` is set when fixed domains matter
- [ ] Chart-wide modifiers are applied to `Chart`, not individual marks
- [ ] `foregroundStyle(by:)` used for categorical series (not manual per-mark colors)
- [ ] Single-value selection uses `chartXSelection(value:)` or `chartYSelection(value:)`
- [ ] Range selection uses `chartXSelection(range:)` or `chartYSelection(range:)`
- [ ] `SectorMark` selection uses `chartAngleSelection(value:)`
- [ ] iOS 17+, iOS 18+, and iOS 26+ APIs are guarded with `#available`
- [ ] `.value()` labels are descriptive for VoiceOver and Audio Graph accessibility
references/charts.md
# SwiftUI Charts Reference

## Table of Contents

- [Overview](#overview)
- [Availability](#availability)
- [Core APIs](#core-apis)
- [Chart Types](#chart-types)
- [Axis Tweaks](#axis-tweaks)
- [Selection APIs](#selection-apis)
- [Annotations](#annotations)
- [ChartProxy and Custom Touch Handling](#chartproxy-and-custom-touch-handling)
- [Modifier Scope](#modifier-scope)
- [Styling and Visual Channels](#styling-and-visual-channels)
- [Composing Multiple Marks](#composing-multiple-marks)
- [Animating Chart Data](#animating-chart-data)
- [Best Practices](#best-practices)

## Overview

Swift Charts is Apple's native charting framework for SwiftUI. Use `Chart` with one or more marks to build bar, line, area, point, rule, rectangle, and sector charts. This reference covers the standard 2D chart APIs, axis customization, built-in selection APIs, annotations, and custom touch handling.

## Availability

Base `Chart`, custom axes, scales, and most marks require iOS 16 or later.

- `BarMark`, `LineMark`, `AreaMark`, `PointMark`, `RectangleMark`, and `RuleMark` are available on iOS 16+
- `SectorMark`, built-in selection, and scrollable chart axes require iOS 17+
- Data-driven plot types such as `BarPlot` and `LinePlot` require iOS 18+
- Chart3D and Z-axis APIs exist on iOS 26+; this reference is primarily about 2D `Chart`, with a dedicated Chart3D section below

```swift
if #available(iOS 17, *) {
    // Selection, SectorMark, scrollable axes
} else {
    // Base Chart, axes, scales, and core marks
}
```

## Core APIs

### Import the Framework

Always check that the file imports `Charts` before using `Chart`, `Chart3D`, `BarMark`, `SectorMark`, or `ChartProxy`.

```swift
import SwiftUI
import Charts
```

If chart types are unresolved, the first thing to verify is that `Charts` is imported in that file.

### Chart Container

`Chart` is the root view. Add one or more marks inside it.

```swift
Chart(sales) { item in
    BarMark(
        x: .value("Month", item.month),
        y: .value("Revenue", item.revenue)
    )
}
```

### Data Models Should Be Identifiable

Prefer `Identifiable` models for chart data so identity stays stable as data changes.

```swift
struct SalesPoint: Identifiable {
    let id: UUID
    let month: String
    let revenue: Double
}
```

If your model cannot conform to `Identifiable`, provide an explicit id key path:

```swift
Chart(sales, id: \.month) { item in
    BarMark(
        x: .value("Month", item.month),
        y: .value("Revenue", item.revenue)
    )
}
```

### Plottable Values

Use `.value(_, _)` to describe what each axis value means. Those labels are reused by axes, legends, and accessibility.

```swift
LineMark(
    x: .value("Day", entry.date),
    y: .value("Steps", entry.count)
)
```

## Chart Types

### BarMark

```swift
BarMark(
    x: .value("Product", product.name),
    y: .value("Units", product.units)
)
```

Stacking via `MarkStackingMethod`: `.standard`, `.normalized`, `.center`, `.unstacked`.

### LineMark

```swift
LineMark(
    x: .value("Day", day.date),
    y: .value("Steps", day.count)
)
.interpolationMethod(.monotone)
```

Interpolation methods: `.linear`, `.monotone`, `.cardinal`, `.catmullRom`, `.stepStart`, `.stepCenter`, `.stepEnd`. Cardinal and Catmull-Rom accept optional tension/alpha parameters.

### AreaMark

```swift
AreaMark(
    x: .value("Hour", sample.hour),
    y: .value("Temperature", sample.value),
    stacking: .unstacked
)
```

Ranged areas use `yStart`/`yEnd` for bands like min/max or confidence intervals:

```swift
AreaMark(
    x: .value("Day", sample.day),
    yStart: .value("Low", sample.low),
    yEnd: .value("High", sample.high)
)
```

### PointMark

```swift
PointMark(
    x: .value("Time", measurement.time),
    y: .value("Value", measurement.value)
)
```

### RectangleMark

```swift
RectangleMark(
    xStart: .value("Start Day", cell.startDay),
    xEnd: .value("End Day", cell.endDay),
    yStart: .value("Low", cell.low),
    yEnd: .value("High", cell.high)
)
```

### RuleMark

```swift
RuleMark(y: .value("Goal", 10_000))
    .foregroundStyle(.red)
```

### SectorMark

Use `SectorMark` for pie and donut-style charts. `SectorMark` requires iOS 17 or later.

```swift
Chart(expenses) { expense in
    SectorMark(
        angle: .value("Amount", expense.amount),
        innerRadius: .ratio(0.6),
        angularInset: 2
    )
    .foregroundStyle(by: .value("Category", expense.category))
}
```

Use `innerRadius` to turn a pie chart into a donut chart, and `angularInset` to separate slices visually.

### Plot Types (iOS 18+)

iOS 18 adds data-driven plot wrappers: `AreaPlot`, `BarPlot`, `LinePlot`, `PointPlot`, `RectanglePlot`, `RulePlot`, and `SectorPlot`.

`LinePlot` and `AreaPlot` also accept function closures for plotting mathematical functions without discrete data:

```swift
if #available(iOS 18, *) {
    Chart {
        LinePlot(x: "x", y: "sin(x)") { x in
            sin(x)
        }
    }
    .chartXScale(domain: -Double.pi ... Double.pi)
    .chartYScale(domain: -1.5 ... 1.5)
}
```

Use plot types when you want a data-first API surface or need function plotting. The underlying chart families stay the same.

### Chart3D (iOS 26+)

`Chart3D` is a separate API for 3D chart content. It supports 3D `PointMark`, `RectangleMark`, `RuleMark`, and `SurfacePlot`.

```swift
if #available(iOS 26, *) {
    Chart3D(points) { point in
        PointMark(
            x: .value("X", point.x),
            y: .value("Y", point.y),
            z: .value("Z", point.z)
        )
    }
    .chart3DPose(.front)
    .chart3DCameraProjection(.perspective)
}
```

`SurfacePlot` visualizes mathematical surfaces by evaluating a two-variable function:

```swift
if #available(iOS 26, *) {
    Chart3D {
        SurfacePlot(x: "x", y: "height", z: "z") { x, z in
            sin(x) * cos(z)
        }
    }
    .chartXScale(domain: -Double.pi ... Double.pi)
    .chartZScale(domain: -Double.pi ... Double.pi)
}
```

Camera and pose configuration:

- **Projection**: `.chart3DCameraProjection(.orthographic)` (default, precise measurements) or `.perspective` (depth effect)
- **Pose presets**: `.chart3DPose(.default)`, `.front`, `.back`, `.left`, `.right`
- **Custom pose**: `.chart3DPose(azimuth: .degrees(45), inclination: .degrees(30))`
- On visionOS, Chart3D supports natural 3D interaction gestures for rotation and exploration

**Always** gate `Chart3D` with `#available(iOS 26, *)` — it is not available on earlier OS versions.

## Axis Tweaks

### Axis Visibility and Labels

Use `chartXAxis`, `chartYAxis`, `chartXAxisLabel`, and `chartYAxisLabel` on the `Chart` container.
Axis visibility supports `.automatic`, `.visible`, and `.hidden`.

```swift
Chart(data) { item in
    BarMark(
        x: .value("Month", item.month),
        y: .value("Revenue", item.revenue)
    )
}
.chartXAxis(.visible)
.chartYAxis(.hidden)
.chartXAxisLabel("Month")
.chartYAxisLabel("Revenue")
```

### Custom Axis Marks

Use `AxisMarks` to control tick placement, labels, and grid lines.

```swift
Chart(steps) { day in
    LineMark(
        x: .value("Day", day.date),
        y: .value("Steps", day.count)
    )
}
.chartXAxis {
    AxisMarks(
        preset: .aligned,
        position: .bottom,
        values: .stride(by: .day)
    ) {
        AxisGridLine()
        AxisTick(length: .label)
        AxisValueLabel(format: .dateTime.weekday(.abbreviated))
    }
}
```

Useful `AxisMarks` inputs:

- `preset`: `.automatic`, `.extended`, `.aligned`, `.inset`
- `position`: `.automatic`, `.leading`, `.trailing`, `.top`, `.bottom`
- `values`: `.automatic`, `.automatic(desiredCount:)`, `.stride(by:)`, `.stride(by:count:)`, or an explicit array

### Axis Components

Within `AxisMarks`, combine the built-in axis components as needed:

```swift
AxisGridLine()
AxisTick()
AxisValueLabel()
```

`AxisValueLabel` can be tuned for dense axes:

```swift
AxisValueLabel(
    collisionResolution: .greedy(minimumSpacing: 8),
    orientation: .vertical
)
```

Label orientations: `.automatic`, `.horizontal`, `.vertical`, `.verticalReversed`.

Collision strategies: `.automatic`, `.greedy`, `.greedy(priority:minimumSpacing:)`, `.truncate`, `.disabled`.

### Axis Domains and Plot Area Tweaks

Use scales when you need explicit axis domains or plot area control.

```swift
Chart(data) { item in
    LineMark(
        x: .value("Index", item.index),
        y: .value("Score", item.score)
    )
}
.chartXScale(domain: 0...30)
.chartYScale(domain: 0...100)
.chartPlotStyle { plotArea in
    plotArea
        .background(.gray.opacity(0.08))
}
```

You can set one axis domain without forcing the other:

```swift
.chartXScale(domain: startDate...endDate)
```

### Scrollable Axes (iOS 17+)

For larger datasets, make the plot area scroll and control the visible domain.

```swift
@State private var scrollX = 7

Chart(data) { item in
    BarMark(
        x: .value("Day", item.day),
        y: .value("Value", item.value)
    )
}
.chartScrollableAxes(.horizontal)
.chartXVisibleDomain(length: 7)
.chartScrollPosition(x: $scrollX)
```

## Selection APIs

### Single-Value Selection

Use `chartXSelection(value:)` or `chartYSelection(value:)` for one selected value.

```swift
@State private var selectedDate: Date?

Chart(steps) { day in
    LineMark(x: .value("Day", day.date), y: .value("Steps", day.count))

    if let selectedDate {
        RuleMark(x: .value("Selected Day", selectedDate))
            .foregroundStyle(.secondary)
    }
}
.chartXSelection(value: $selectedDate)
```

### Range Selection

Use `chartXSelection(range:)` or `chartYSelection(range:)` for a dragged range. Bind to a `ClosedRange` whose bound type matches the plotted axis value.

```swift
@State private var selectedWeeks: ClosedRange<Int>?

Chart(weeks) { week in
    BarMark(x: .value("Week", week.index), y: .value("Revenue", week.revenue))
}
.chartXSelection(range: $selectedWeeks)
```

### Choosing Single vs Range

- Use `value:` bindings when only one point or axis value should be selected.
- Use `range:` bindings when users should brush a span (for zoom windows, comparisons, or grouped summaries).

### Angle Selection

Use `chartAngleSelection(value:)` with `SectorMark` charts. No built-in range overload for angle selection.

```swift
@State private var selectedAmount: Double?

Chart(expenses) { expense in
    SectorMark(angle: .value("Amount", expense.amount))
        .foregroundStyle(by: .value("Category", expense.category))
}
.chartAngleSelection(value: $selectedAmount)
```

**Important**: Selection bindings return the plottable axis value, not the full data element. Map back to your model if you need the selected record.

## Annotations

Use `annotation(position:)` on a mark when you need labels, callouts, or highlighted values attached to the plotted content.

```swift
BarMark(
    x: .value("Month", item.month),
    y: .value("Revenue", item.revenue)
)
.annotation(position: .top) {
    Text(item.revenue.formatted())
}
```

This is useful for selected values, thresholds, summaries, and direct labeling. Common positions include `.overlay`, `.top`, `.bottom`, `.leading`, and `.trailing`.

## ChartProxy and Custom Touch Handling

Use `chartOverlay`/`chartBackground` (iOS 16+) or `chartGesture` (iOS 17+) with `ChartProxy` when built-in selection modifiers are not enough.

```swift
.chartOverlay { proxy in
    GeometryReader { geometry in
        Rectangle().fill(.clear).contentShape(Rectangle())
            .gesture(
                DragGesture(minimumDistance: 0)
                    .onChanged { value in
                        guard let plotFrame = proxy.plotFrame else { return } // iOS 16: use proxy.plotAreaFrame
                        let frame = geometry[plotFrame]
                        let x = value.location.x - frame.origin.x
                        guard x >= 0, x <= frame.size.width else { return }
                        selectedDate = proxy.value(atX: x, as: Date.self)
                    }
                    .onEnded { _ in selectedDate = nil }
            )
    }
}
```

Use `proxy.plotFrame` (iOS 17+) or `proxy.plotAreaFrame` (iOS 16) to get the plot area anchor.

`ChartProxy` gives you lower-level access to:

- `value(atX:as:)`, `value(atY:as:)`, and `value(at:as:)` for converting gesture coordinates into chart values
- `position(forX:)`, `position(forY:)`, and `position(for:)` for placing custom overlays or indicators
- `selectXValue(at:)`, `selectYValue(at:)`, `selectXRange(from:to:)`, and `selectYRange(from:to:)` for driving built-in selection from custom gestures
- `plotFrame` (iOS 17+) or `plotAreaFrame` (iOS 16) with `plotSize` for converting between gesture coordinates and the plot area

`select*` ChartProxy selection methods and `chartGesture` are available on iOS 17+.

## Modifier Scope

Apply chart-wide modifiers to the `Chart` container and mark-specific modifiers to the individual mark.

```swift
Chart(data) { item in
    LineMark(
        x: .value("Day", item.date),
        y: .value("Value", item.value)
    )
    .interpolationMethod(.monotone)   // Mark-level modifier
}
.chartXAxis { AxisMarks() }            // Chart-level modifier
.chartYScale(domain: 0...100)          // Chart-level modifier
.chartPlotStyle { $0.background(.thinMaterial) }
```

## Styling and Visual Channels

### Categorical Coloring

Use `foregroundStyle(by: .value(...))` to color marks by a data property. Swift Charts generates a legend automatically.

```swift
Chart(sales) { item in
    BarMark(
        x: .value("Month", item.month),
        y: .value("Revenue", item.revenue)
    )
    .foregroundStyle(by: .value("Region", item.region))
}
```

**Avoid** applying `.foregroundStyle(.red)` per mark for categorical data — this suppresses the automatic legend and breaks accessibility.

### Custom Color Scales

Use `chartForegroundStyleScale` to control the mapping from data values to colors.

```swift
.chartForegroundStyleScale([
    "North": .blue,
    "South": .orange,
    "East": .green
])
```

For dynamic data where not all series appear at every point, use the mapping overload:

```swift
.chartForegroundStyleScale(domain: regions, mapping: { region in
    colorForRegion(region)
})
```

### Symbol and Size Channels

Use `symbol(by:)` and `symbolSize(by:)` to encode additional data dimensions on `PointMark` and `LineMark`.

```swift
Chart(measurements) { item in
    PointMark(
        x: .value("Time", item.time),
        y: .value("Value", item.value)
    )
    .foregroundStyle(by: .value("Category", item.category))
    .symbol(by: .value("Category", item.category))
    .symbolSize(by: .value("Weight", item.weight))
}
```

### Legend Control

```swift
.chartLegend(.visible)
.chartLegend(.hidden)
.chartLegend(position: .bottom, alignment: .center)
```

## Composing Multiple Marks

Combine different mark types inside the same `Chart` closure:

```swift
// Line with points
LineMark(x: .value("Day", day.date), y: .value("Steps", day.count))
    .interpolationMethod(.monotone)
PointMark(x: .value("Day", day.date), y: .value("Steps", day.count))

// Bars with threshold line
BarMark(x: .value("Month", item.month), y: .value("Revenue", item.revenue))
RuleMark(y: .value("Target", 10_000))
    .foregroundStyle(.red)
    .lineStyle(StrokeStyle(dash: [5, 3]))
```

## Animating Chart Data

Chart marks animate automatically when data identity is stable and changes are wrapped in an animation.

```swift
withAnimation(.easeInOut) {
    chartData = updatedData
}
```

**Always** use `Identifiable` models (or explicit `id:`) so Swift Charts can match old and new data points and animate transitions between them.

## Best Practices

### Do

- Use semantic `.value(_, _)` labels so axes and accessibility read clearly
- Prefer `Identifiable` models (or explicit `id:`) for stable chart data identity
- Use `foregroundStyle(by:)` for categorical series to get automatic legends and accessibility
- Use `RuleMark` for goals, thresholds, and selected-value indicators
- Use explicit `AxisMarks(values:)` when automatic tick generation gets crowded
- Use `chartXScale` and `chartYScale` when you need stable visual comparisons
- Use `chartXSelection(range:)` or `chartYSelection(range:)` for brushed selection
- Gate iOS 17+ APIs such as `SectorMark` and selection with `#available`

### Don't

- Put chart-wide modifiers such as `chartXAxis` or `chartXSelection` on individual marks
- Apply manual `.foregroundStyle(.color)` per mark for categorical data — use `foregroundStyle(by:)` instead
- Rely on unstable identities when chart data can be inserted, removed, or reordered
- Use string values for naturally numeric or date-based axes unless you want categorical behavior
- Stack unrelated series by default just because `BarMark` and `AreaMark` allow it
- Force every tick label to display when collision handling or stride values would be clearer
- Assume selection returns a model object; it only returns the plottable axis value
- Forget that range selection is available only for X and Y axes, not angle selection

For chart accessibility (VoiceOver, Audio Graph, `AXChartDescriptorRepresentable`), fallback strategies, WWDC sessions, and a full summary checklist, see `charts-accessibility.md`.
references/focus-patterns.md
# SwiftUI Focus Patterns Reference

## Table of Contents

- [@FocusState](#focusstate)
- [Making Views Focusable](#making-views-focusable)
- [Focused Values for Commands and Menus](#focused-values-for-commands-and-menus)
- [Default Focus](#default-focus)
- [Focus Scope and Sections](#focus-scope-and-sections)
- [Focus Effects](#focus-effects)
- [Search Focus](#search-focus)
- [Common Pitfalls](#common-pitfalls)

## @FocusState

Always mark `@FocusState` as `private`. Use `Bool` for a single field, an optional `Hashable` enum for multiple fields.

### Single field

```swift
@FocusState private var isFocused: Bool

TextField("Email", text: $email)
    .focused($isFocused)
```

### Multiple fields

```swift
enum Field: Hashable { case name, email, password }
@FocusState private var focusedField: Field?

TextField("Name", text: $name)
    .focused($focusedField, equals: .name)
TextField("Email", text: $email)
    .focused($focusedField, equals: .email)
```

Set `focusedField = .email` to move focus programmatically; set `nil` to dismiss the keyboard.

### `focused(_:)` vs `focused(_:equals:)` with nested views

`.focused($bool)` reports `true` when the modified view *or any focusable descendant* has focus. `.focused($enum, equals:)` reports its value only when that specific view receives focus.

```swift
enum Focus: Hashable { case container, field }
@FocusState private var focus: Focus?

VStack {
    TextField("Name", text: $name)
        .focused($focus, equals: .field)
}
.focusable()
.focused($focus, equals: .container)
```

With `focused(_:equals:)` and a single `@FocusState`, SwiftUI distinguishes the container *receiving* focus from the container merely *containing* focus.

### `isFocused` environment value

Read-only environment value that returns `true` when the nearest focusable ancestor has focus. Useful for styling non-focusable child views.

```swift
struct HighlightWrapper: View {
    @Environment(\.isFocused) private var isFocused

    var body: some View {
        content
            .background(isFocused ? Color.accentColor.opacity(0.1) : .clear)
    }
}
```

## Making Views Focusable

### `.focusable(_:)`

Makes a non-text-input view participate in the focus system. Focused views can respond to keyboard events via `onKeyPress` and menu commands like Edit > Delete via `onDeleteCommand`.

```swift
struct SelectableCard: View {
    @FocusState private var isFocused: Bool

    var body: some View {
        CardContent()
            .focusable()
            .focused($isFocused)
            .border(isFocused ? Color.accentColor : .clear)
            .onDeleteCommand { deleteCard() }
    }
}
```

### `.focusable(_:interactions:)` (iOS 17+)

Controls which focus-driven interactions the view supports via `FocusInteractions`:

- `.activate` -- Button-like: only focusable when system-wide keyboard navigation is on (macOS/iOS)
- `.edit` -- Captures keyboard/Digital Crown input
- `.automatic` -- Platform default (both activate and edit)

```swift
MyTapGestureView(...)
    .focusable(interactions: .activate)
```

Use `.activate` for custom button-like views that should match system keyboard-navigation behavior.

## Focused Values for Commands and Menus

Focused values let parent views (App, Scene, Commands) read state from whichever view currently has focus. Use for enabling/disabling menu commands based on the focused document or selection.

### Declare with `@Entry`

```swift
extension FocusedValues {
    @Entry var selectedDocument: Binding<Document>?
}
```

Focused values are typically optional (default is `nil` when no view publishes them), but you can also use non-optional entries when you have a sensible default value.

### Publish from views

```swift
// View-scoped: available when this view (or descendant) has focus
.focusedValue(\.selectedDocument, $document)

// Scene-scoped: available when this scene has focus
.focusedSceneValue(\.selectedDocument, $document)
```

### Consume in commands

`@FocusedValue` reads the value; `@FocusedBinding` unwraps a `Binding` automatically.

```swift
@main
struct MyApp: App {
    @FocusedBinding(\.selectedDocument) var document

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            CommandGroup(after: .pasteboard) {
                Button("Duplicate") { document?.duplicate() }
                    .disabled(document == nil)
            }
        }
    }
}
```

### `@FocusedObject` (iOS 16+)

For `ObservableObject` types. The view invalidates when the focused object changes.

```swift
// Publish
.focusedObject(myObservableModel)

// Consume
@FocusedObject var model: MyModel?
```

Scene-scoped variant: `.focusedSceneObject(_:)`.

## Default Focus

### `.defaultFocus(_:_:priority:)` (iOS 17+, macOS 13+, tvOS 16+)

Prefer `.defaultFocus` over setting `@FocusState` in `onAppear` for initial focus placement.

```swift
@FocusState private var focusedField: Field?

VStack {
    TextField("Name", text: $name)
        .focused($focusedField, equals: .name)
    TextField("Email", text: $email)
        .focused($focusedField, equals: .email)
}
.defaultFocus($focusedField, .email)
```

**Priority**: `.automatic` (default) applies on window appearance and programmatic focus changes. `.userInitiated` also applies during user-driven focus navigation.

### `prefersDefaultFocus(_:in:)` (macOS/tvOS/watchOS)

Used with `.focusScope(_:)` to mark a preferred default target within a scoped region.

### `resetFocus` environment action (macOS/tvOS/watchOS)

Re-evaluates default focus within a namespace.

```swift
@Namespace var scopeID
@Environment(\.resetFocus) private var resetFocus

Button("Reset") { resetFocus(in: scopeID) }
```

## Focus Scope and Sections

### `.focusScope(_:)` (macOS/tvOS/watchOS)

Limits default focus preferences to a namespace. Use with `prefersDefaultFocus` and `resetFocus`.

### `.focusSection()` (macOS 13+, tvOS 15+)

Guides directional and sequential focus movement through a group of focusable descendants. Useful when focusable views are spatially separated and directional navigation would otherwise skip them.

```swift
HStack {
    VStack { Button("1") {}; Button("2") {}; Spacer() }
    Spacer()
    VStack { Spacer(); Button("A") {}; Button("B") {} }
        .focusSection()
}
```

Without `.focusSection()`, swiping right from buttons 1/2 finds nothing. With it, the VStack receives directional focus and delivers it to its first focusable child.

## Focus Effects

### `.focusEffectDisabled(_:)`

Suppresses the system focus ring (macOS) or hover effect. Use when providing custom focus visuals.

```swift
MyCustomCard()
    .focusable()
    .focusEffectDisabled()
    .overlay { customFocusRing }
```

`isFocusEffectEnabled` environment value reads the current state.

## Search Focus

### `.searchFocused(_:)` / `.searchFocused(_:equals:)`

Bind focus state to the search field associated with the nearest `.searchable` modifier. Works like `.focused` but targets the search bar.

```swift
@FocusState private var isSearchFocused: Bool

NavigationStack {
    ContentView()
        .searchable(text: $query)
        .searchFocused($isSearchFocused)
}

// Programmatically focus the search bar
Button("Search") { isSearchFocused = true }
```

## Common Pitfalls

### Redundant `@FocusState` writes revoke focus

`.focusable()` + `.focused()` handles focus-on-click natively. Adding a tap gesture that *also* writes to `@FocusState` triggers a redundant state write, causing a second body evaluation that revokes focus. The result: focus briefly appears then disappears, and key commands like `onDeleteCommand` stop working.

```swift
// WRONG -- tap gesture redundantly sets focus, causing double evaluation
CardView()
    .focusable()
    .focused($isFocused)
    .onTapGesture { isFocused = true }  // Remove this line

// CORRECT -- let .focusable() + .focused() handle it
CardView()
    .focusable()
    .focused($isFocused)
```

### Ambiguous focus bindings

Binding the same enum case to multiple views is ambiguous. SwiftUI picks the first candidate and emits a runtime warning.

```swift
// WRONG -- .name bound to two views
TextField("Name", text: $name)
    .focused($focusedField, equals: .name)
TextField("Full Name", text: $fullName)
    .focused($focusedField, equals: .name)  // ambiguous
```

Always use distinct enum cases for each focusable view.

### `.onAppear` focus timing

Setting `@FocusState` in `.onAppear` may fail if the view tree hasn't settled. Prefer `.defaultFocus` (iOS 17+) for reliable initial focus. If you must use `.onAppear`, wrap in `DispatchQueue.main.async` as a last resort.

### Missing `.focusable()` for non-text views

`TextField` and `SecureField` are implicitly focusable. Custom views (stacks, shapes, images) are not. Forgetting `.focusable()` means `.focused()` bindings have no effect and key event handlers never fire.
references/image-optimization.md
# SwiftUI Image Optimization Reference

## Table of Contents

- [AsyncImage Best Practices](#asyncimage-best-practices)
- [Image Decoding and Downsampling (Optional Optimization)](#image-decoding-and-downsampling-optional-optimization)
- [UIImage Loading and Memory](#uiimage-loading-and-memory)
- [SF Symbols](#sf-symbols)
- [Summary Checklist](#summary-checklist)

## AsyncImage Best Practices

### Basic AsyncImage with Phase Handling

```swift
// Good - handles loading and error states
AsyncImage(url: imageURL) { phase in
    switch phase {
    case .empty:
        ProgressView()
    case .success(let image):
        image
            .resizable()
            .aspectRatio(contentMode: .fit)
    case .failure:
        Image(systemName: "photo")
            .foregroundStyle(.secondary)
    @unknown default:
        EmptyView()
    }
}
.frame(width: 200, height: 200)
```

For custom placeholders, replace `ProgressView()` in the `.empty` case with your placeholder view. Add `.transition(.opacity)` to the success case and `.animation(.easeInOut, value: imageURL)` to the container for fade-in transitions.

## Image Decoding and Downsampling (Optional Optimization)

**When you encounter `UIImage(data:)` usage, consider suggesting image downsampling as a potential performance improvement**, especially for large images in lists or grids.

### Current Pattern That Could Be Optimized

```swift
// Current pattern - decodes full image on main thread
// Unsafe - force unwrap can crash if imageData is invalid
Image(uiImage: UIImage(data: imageData)!)
    .resizable()
    .aspectRatio(contentMode: .fit)
    .frame(width: 200, height: 200)
```

### Suggested Optimization Pattern

```swift
// Suggested optimization - decode and downsample off main thread
struct OptimizedImageView: View {
    let imageData: Data
    let targetSize: CGSize
    @State private var processedImage: UIImage?
    
    var body: some View {
        Group {
            if let processedImage {
                Image(uiImage: processedImage)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
            } else {
                ProgressView()
            }
        }
        .task {
            processedImage = await decodeAndDownsample(imageData, targetSize: targetSize)
        }
    }
    
    private func decodeAndDownsample(_ data: Data, targetSize: CGSize) async -> UIImage? {
        await Task.detached {
            guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
                return nil
            }
            
            let options: [CFString: Any] = [
                kCGImageSourceThumbnailMaxPixelSize: max(targetSize.width, targetSize.height),
                kCGImageSourceCreateThumbnailFromImageAlways: true,
                kCGImageSourceCreateThumbnailWithTransform: true
            ]
            
            guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
                return nil
            }
            
            return UIImage(cgImage: cgImage)
        }.value
    }
}

// Usage
OptimizedImageView(
    imageData: imageData,
    targetSize: CGSize(width: 200, height: 200)
)
```

### Reusable Downsampling Actor

For production use, wrap the logic in an `actor` with scale-aware sizing and cache-disabled source options:

```swift
actor ImageProcessor {
    func downsample(data: Data, targetSize: CGSize) -> UIImage? {
        let scale = await UIScreen.main.scale
        let maxPixel = max(targetSize.width, targetSize.height) * scale
        let sourceOptions: [CFString: Any] = [kCGImageSourceShouldCache: false]
        guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions as CFDictionary) else { return nil }
        let downsampleOptions: [CFString: Any] = [
            kCGImageSourceCreateThumbnailFromImageAlways: true,
            kCGImageSourceThumbnailMaxPixelSize: maxPixel,
            kCGImageSourceCreateThumbnailWithTransform: true,
            kCGImageSourceShouldCacheImmediately: true
        ]
        guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary) else { return nil }
        return UIImage(cgImage: cgImage)
    }
}
```

Key details: `kCGImageSourceShouldCache: false` on the source prevents the full-resolution image from being cached in memory. Multiplying `targetSize` by `UIScreen.main.scale` ensures the thumbnail is sharp on Retina displays. `kCGImageSourceShouldCacheImmediately: true` on the thumbnail forces decoding at creation time rather than at first render.

### When to Suggest This Optimization

Mention this optimization when you see `UIImage(data:)` usage, particularly in:
- Scrollable content (List, ScrollView with LazyVStack/LazyHStack)
- Grid layouts with many images
- Image galleries or carousels
- Any scenario where large images are displayed at smaller sizes

**Don't automatically apply it**—present it as an optional improvement for performance-sensitive scenarios.

## UIImage Loading and Memory

### UIImage(named:) Caches in System Cache

`UIImage(named:)` adds images to the system cache, which can cause memory spikes when loading many images (e.g., in a slider or gallery). For single-use or frequently-rotated images, use `UIImage(contentsOfFile:)` to bypass the cache:

```swift
// Caches in system cache -- memory builds up
let image = UIImage(named: "Wallpapers/image_001.jpg")

// No system caching -- memory stays flat
guard let path = Bundle.main.path(forResource: "Wallpapers/image_001.jpg", ofType: nil) else { return nil }
let image = UIImage(contentsOfFile: path)
```

### NSCache for Controlled Image Caching

When image processing (resizing, filtering) is needed, use `NSCache` with a `countLimit` to bound memory instead of relying on system caching:

```swift
struct ImageCache {
    private let cache = NSCache<NSString, UIImage>()

    init(countLimit: Int = 50) {
        cache.countLimit = countLimit
    }

    subscript(key: String) -> UIImage? {
        get { cache.object(forKey: key as NSString) }
        nonmutating set {
            if let newValue {
                cache.setObject(newValue, forKey: key as NSString)
            } else {
                cache.removeObject(forKey: key as NSString)
            }
        }
    }
}
```

## SF Symbols

```swift
Image(systemName: "star.fill")
    .foregroundStyle(.yellow)
    .symbolRenderingMode(.multicolor)     // or .hierarchical, .palette, .monochrome

// Animated symbols (iOS 17+)
Image(systemName: "antenna.radiowaves.left.and.right")
    .symbolEffect(.variableColor)
```

Variants are available via naming convention: `star.circle.fill`, `star.square.fill`, `folder.badge.plus`.

## Summary Checklist

- [ ] Use `AsyncImage` with proper phase handling
- [ ] Handle empty, success, and failure states
- [ ] Consider downsampling for `UIImage(data:)` in performance-sensitive scenarios
- [ ] Decode and downsample images off the main thread
- [ ] Use appropriate target sizes for downsampling
- [ ] Consider image caching for frequently accessed images
- [ ] Use SF Symbols with appropriate rendering modes

**Performance Note**: Image downsampling is an optional optimization. Only suggest it when you encounter `UIImage(data:)` usage in performance-sensitive contexts like scrollable lists or grids.
references/latest-apis.md
# Latest SwiftUI APIs Reference

> Based on a comparison of Apple's documentation using the Sosumi MCP, we found the latest recommended APIs to use.

> This file lists *what* the modern replacements are. For *how to behave* when you find a soft-deprecated API — when to migrate, when to leave it alone, and the scoping rule for unrelated edits — see `references/soft-deprecation.md`. To refresh this list after a new SDK release, run the maintenance skill at `.agents/skills/update-swiftui-apis/SKILL.md`.

## Table of Contents
- [Always Use (iOS 15+)](#always-use-ios-15)
- [When Targeting iOS 16+](#when-targeting-ios-16)
- [When Targeting iOS 17+](#when-targeting-ios-17)
- [When Targeting iOS 18+](#when-targeting-ios-18)
- [When Targeting iOS 26+](#when-targeting-ios-26)

---

## Always Use (iOS 15+)

These APIs have been deprecated long enough that there is no reason to use the old variants.

### Compact Replacements

These replacements have minimal API shape changes. Most are near-direct swaps; a few require an additional parameter or structural adjustment:

- **`navigationTitle(_:)`** instead of `navigationBarTitle(_:)`
- **`toolbar { ToolbarItem(...) }`** instead of `navigationBarItems(...)` (structural change)
- **`toolbarVisibility(.hidden, for: .navigationBar)`** instead of `navigationBarHidden(_:)`
- **`statusBarHidden(_:)`** instead of `statusBar(hidden:)`
- **`ignoresSafeArea(_:edges:)`** instead of `edgesIgnoringSafeArea(_:)`
- **`preferredColorScheme(_:)`** instead of `colorScheme(_:)`
- **`foregroundStyle(_:)`** instead of `foregroundColor(_:)` (e.g., `.foregroundStyle(.primary)`)
- **`clipShape(.rect(cornerRadius:))`** instead of `cornerRadius()`
- **`textInputAutocapitalization(_:)`** instead of `autocapitalization(_:)` (note: `.never` replaces `.none`)
- **`animation(_:value:)`** instead of `animation(_:)` (adds required `value:` parameter; back-deploys to iOS 13+)

### Lists and Forms

**Use trailing-closure `Section` initializers instead of the positional header/footer View initializers.**

The single-title form is still current and should not be treated as deprecated:

```swift
// Current - single-title LocalizedStringKey initializer
Section("Settings") {
    Toggle("Notifications", isOn: .constant(true))
}

// Replacement - content/header/footer trailing-closure initializer
Section {
    Toggle("Notifications", isOn: .constant(true))
} header: {
    Text("Settings")
} footer: {
    Text("Changes apply immediately.")
}

// Deprecated/renamed - positional header/footer View arguments
Section(header: Text("Settings"), footer: Text("Changes apply immediately.")) {
    Toggle("Notifications", isOn: .constant(true))
}

Section(header: Text("Settings")) {
    Toggle("Notifications", isOn: .constant(true))
}

Section(footer: Text("Changes apply immediately.")) {
    Toggle("Notifications", isOn: .constant(true))
}
```

### Presentation

- **Always use `.confirmationDialog(_:isPresented:actions:message:)`** instead of `actionSheet(...)`.
- **Always use `.alert(_:isPresented:actions:message:)`** instead of `alert(isPresented:content:)`.

Both take a title `String`, `isPresented: Binding<Bool>`, an `actions` builder with `Button` items (supporting `role: .destructive` / `.cancel`), and an optional `message` builder:

```swift
.alert("Delete Item?", isPresented: $showAlert) {
    Button("Delete", role: .destructive) { deleteItem() }
    Button("Cancel", role: .cancel) { }
} message: {
    Text("This action cannot be undone.")
}
```

### Text Input

**Always use `onSubmit(of:_:)` and `focused(_:equals:)` instead of `TextField` `onEditingChanged`/`onCommit` callbacks.**

```swift
@FocusState private var isFocused: Bool

TextField("Search", text: $query)
    .focused($isFocused)
    .onSubmit { performSearch() }
```

### Accessibility

**Always use dedicated accessibility modifiers instead of the generic `accessibility(...)` variants.** Use `.accessibilityLabel()`, `.accessibilityValue()`, `.accessibilityHint()`, `.accessibilityAddTraits()`, `.accessibilityHidden()` instead of `.accessibility(label:)`, `.accessibility(value:)`, etc.

### Custom Environment / Container Values

**Always use the `@Entry` macro instead of manual `EnvironmentKey` conformance.** The `@Entry` macro was introduced in Xcode 16 and back-deploys to all OS versions.

```swift
// Modern — one line replaces ~10 lines of EnvironmentKey boilerplate
extension EnvironmentValues {
    @Entry var myCustomValue: String = "Default value"
}
```

### Styling

**Always use `Button` instead of `onTapGesture()` unless you need tap location or count.**

```swift
Button("Tap me") { performAction() }

// Use onTapGesture only when you need location or count
Image("photo")
    .onTapGesture(count: 2) { handleDoubleTap() }
```

---

## When Targeting iOS 16+

### Navigation

**Use `NavigationStack` (or `NavigationSplitView`) instead of `NavigationView`.** Value-based `NavigationLink(value:)` with `.navigationDestination(for:)` replaces destination-based links.

```swift
NavigationStack {
    List(items) { item in
        NavigationLink(value: item) { Text(item.name) }
    }
    .navigationDestination(for: Item.self) { DetailView(item: $0) }
}
```

### Simple Renames

- **`tint(_:)`** instead of `accentColor(_:)`
- **`autocorrectionDisabled(_:)`** instead of `disableAutocorrection(_:)`

### Clipboard

**Prefer `PasteButton` for user-initiated paste UI** to avoid paste prompts. It handles permissions automatically. Use `UIPasteboard` only when you need programmatic or non-`Transferable` clipboard access (triggers the paste permission prompt).

```swift
PasteButton(payloadType: String.self) { strings in
    pastedText = strings.first ?? ""
}
```

---

## When Targeting iOS 17+

### State Management

- **Prefer `@Observable` over `ObservableObject` for new code.** Use `@State` instead of `@StateObject`; use `@Bindable` instead of `@ObservedObject`. See `state-management.md` for full `@Observable` migration patterns.

### Events

**Use `onChange(of:initial:_:)` or `onChange(of:) { }` instead of `onChange(of:perform:)`.**

The deprecated variant passes only the new value. The modern variants provide either both old and new values, or a no-parameter closure.

- **No-parameter** (most common): `.onChange(of: value) { doSomething() }`
- **Old and new values**: `.onChange(of: value) { old, new in ... }`
- **With initial trigger**: `.onChange(of: value, initial: true) { ... }`
- **Deprecated**: `.onChange(of: value) { newValue in ... }` — single-parameter closure

### Sensory Feedback

**Prefer `sensoryFeedback(_:trigger:)` and related overloads instead of `UIImpactFeedbackGenerator`, `UISelectionFeedbackGenerator`, and `UINotificationFeedbackGenerator` in SwiftUI views.**

Attach haptics declaratively to the view that owns the state change, rather than imperatively firing UIKit generators inside button actions.

```swift
@State private var isFavorite = false

Button("Favorite", systemImage: isFavorite ? "heart.fill" : "heart") {
    isFavorite.toggle()
}
.sensoryFeedback(.selection, trigger: isFavorite)
```

Use the conditional overload when feedback should fire only for specific transitions:

```swift
.sensoryFeedback(.selection, trigger: phase) { old, new in
    old == .inactive || new == .expanded
}
```

### Gestures

- **`MagnifyGesture`** instead of `MagnificationGesture` (access magnitude via `value.magnification`)
- **`RotateGesture`** instead of `RotationGesture` (access angle via `value.rotation`)

### Layout

**Consider `containerRelativeFrame()` or `visualEffect()` as alternatives to `GeometryReader` for sizing and position-based effects.** `GeometryReader` is not deprecated and remains necessary for many measurement-based layouts.

```swift
Image("hero")
    .resizable()
    .containerRelativeFrame(.horizontal) { length, axis in length * 0.8 }
```

- **`visualEffect { content, geometry in ... }`** — position-based effects (parallax, offsets) without a `GeometryReader` wrapper.
- **`onGeometryChange(for:of:action:)`** — react to geometry changes of a specific view; useful for driving state/effects. `GeometryReader` is still better when layout itself depends on geometry. Note the two-closure shape:
  ```swift
  .onGeometryChange(for: CGFloat.self) { proxy in proxy.size.height } action: { newHeight in height = newHeight }
  ```
- **`.coordinateSpace(.named("scroll"))`** instead of `.coordinateSpace(name: "scroll")`.

---

## When Targeting iOS 18+

### Tabs

**Use the `Tab` API instead of `tabItem(_:)`.**

```swift
TabView {
    Tab("Home", systemImage: "house") { HomeView() }
    Tab("Search", systemImage: "magnifyingglass") { SearchView() }
    Tab("Profile", systemImage: "person") { ProfileView() }
}
```

When using `Tab(role:)`, all tabs must use the `Tab` syntax. Mixing `Tab(role:)` with `.tabItem()` causes compilation errors.

### Previews

**Use `@Previewable` for dynamic properties in previews.**

```swift
// Modern (iOS 18+)
#Preview {
    @Previewable @State var isOn = false
    Toggle("Setting", isOn: $isOn)
}
```

---

## When Targeting iOS 26+

For Liquid Glass APIs (`glassEffect`, `GlassEffectContainer`, glass button styles), see [liquid-glass.md](liquid-glass.md).

### Scroll Edge Effects

**Use `scrollEdgeEffectStyle(_:for:)` to configure scroll edge behavior.**

```swift
ScrollView {
    // content
}
.scrollEdgeEffectStyle(.soft, for: .top)
```

### Background Extension

**Use `backgroundExtensionEffect()` for edge-extending blurred backgrounds.**

Views behind a Liquid Glass sidebar can appear clipped. This modifier mirrors and blurs content outside the safe area so artwork remains visible.

```swift
Image("hero")
    .backgroundExtensionEffect()
```

> Source: "Build a SwiftUI app with the new design" (WWDC25, session 323)

### Tab Bar

**Use `tabBarMinimizeBehavior(_:)` to control tab bar minimization on scroll.**

```swift
TabView {
    // tabs
}
.tabBarMinimizeBehavior(.onScrollDown)
```

**Use `tabViewBottomAccessory` for persistent controls above the tab bar.** Read `tabViewBottomAccessoryPlacement` from the environment to adapt content when the accessory collapses into the tab bar area.

```swift
TabView {
    // tabs
}
.tabViewBottomAccessory {
    NowPlayingBar()
}
```

**Use `Tab(role: .search)` for a dedicated search tab.** The tab separates from the rest and morphs into a search field when selected.

```swift
TabView {
    Tab("Home", systemImage: "house") { HomeView() }
    Tab("Profile", systemImage: "person") { ProfileView() }
    Tab(role: .search) { SearchResultsView() }
}
```

> Source: "What's new in SwiftUI" (WWDC25, session 256) and "Build a SwiftUI app with the new design" (WWDC25, session 323)

### Toolbars

**Use `ToolbarSpacer` to control grouping of toolbar items.** Fixed spacers visually separate related groups; flexible spacers push items apart.

```swift
.toolbar {
    ToolbarItem(placement: .topBarTrailing) {
        Button("Up", systemImage: "chevron.up") { }
    }
    ToolbarItem(placement: .topBarTrailing) {
        Button("Down", systemImage: "chevron.down") { }
    }
    ToolbarSpacer(.fixed)
    ToolbarItem(placement: .topBarTrailing) {
        Button("Settings", systemImage: "gear") { }
    }
}
```

**Use `sharedBackgroundVisibility(.hidden)` to remove the glass group background from an individual toolbar item.**

```swift
ToolbarItem(placement: .topBarTrailing) {
    Image(systemName: "person.circle.fill")
        .sharedBackgroundVisibility(.hidden)
}
```

**Use `badge(_:)` on toolbar item content to display an indicator.**

```swift
ToolbarItem(placement: .topBarTrailing) {
    Button("Notifications", systemImage: "bell") { }
        .badge(unreadCount)
}
```

> Source: "Build a SwiftUI app with the new design" (WWDC25, session 323)

### Search

**Use `searchToolbarBehavior(.minimizable)` to opt into a minimized search button.** The system may automatically minimize search into a toolbar button depending on available space. Use this modifier to explicitly opt in.

```swift
NavigationStack {
    ContentView()
        .searchable(text: $query)
        .searchToolbarBehavior(.minimizable)
}
```

> Source: "Build a SwiftUI app with the new design" (WWDC25, session 323)

### Animations

**Use `@Animatable` macro instead of manual `animatableData` declarations.** The macro auto-synthesizes `animatableData` from all animatable properties. Use `@AnimatableIgnored` to exclude specific properties.

```swift
@Animatable
struct Wedge: Shape {
    var startAngle: Angle
    var endAngle: Angle
    @AnimatableIgnored var drawClockwise: Bool

    func path(in rect: CGRect) -> Path { /* ... */ }
}
```

> Source: "What's new in SwiftUI" (WWDC25, session 256)

### Presentations

**Use `navigationZoomTransition` to morph sheets out of their source view.** Toolbar items and buttons can serve as the transition source.

```swift
.toolbar {
    ToolbarItem {
        Button("Add", systemImage: "plus") { showSheet = true }
            .navigationTransitionSource(id: "addSheet", namespace: namespace)
    }
}
.sheet(isPresented: $showSheet) {
    AddItemView()
        .navigationTransitionDestination(id: "addSheet", namespace: namespace)
}
```

> Source: "Build a SwiftUI app with the new design" (WWDC25, session 323)

### Controls

**Use `controlSize(.extraLarge)` for extra-large prominent action buttons.**

```swift
Button("Get Started") { }
    .buttonStyle(.borderedProminent)
    .controlSize(.extraLarge)
```

**Use `concentric` corner style for buttons that match their container's corners.**

```swift
Button("Confirm") { }
    .clipShape(.rect(cornerRadius: 12, style: .concentric))
```

**Sliders now support tick marks and a neutral value.**

```swift
Slider(value: $speed, in: 0.5...2.0, step: 0.25) {
    Text("Speed")
} ticks: {
    SliderTick(value: 0.6)
    SliderTick(value: 0.9)
}
.sliderNeutralValue(1.0)
```

> Source: "Build a SwiftUI app with the new design" (WWDC25, session 323)

### Rich Text

**Use `TextEditor` with an `AttributedString` binding for rich text editing.** Supports bold, italic, underline, strikethrough, custom fonts, foreground/background colors, paragraph styles, and Genmoji.

```swift
@State private var text: AttributedString = "Hello, world!"

var body: some View {
    TextEditor(text: $text)
}
```

> Source: "Cook up a rich text experience in SwiftUI with AttributedString" (WWDC25, session 280)

### Web Content

**Use `WebView` to display web content.** For richer interaction, create a `WebPage` observable model.

```swift
// Simple URL display
WebView(url: URL(string: "https://example.com")!)

// With observable model
@State private var page = WebPage()

WebView(page)
    .onAppear { page.load(URLRequest(url: myURL)) }
    .navigationTitle(page.title ?? "")
```

> Source: "Meet WebKit for SwiftUI" (WWDC25, session 231)

### Drag and Drop

**Use `dragContainer` for multi-item drag operations.** Combine with `DragConfiguration` for custom drag behavior and `onDragSessionUpdated` to observe events.

```swift
PhotoGrid(photos: photos)
    .dragContainer(for: Photo.self) { selection in
        return selection.map { $0.transferable }
    }
    .onDragSessionUpdated { session in
        if session.phase == .endedWithDelete {
            deleteSelectedPhotos()
        }
    }
```

> Source: "What's new in SwiftUI" (WWDC25, session 256)

### Scene Bridging

**UIKit and AppKit lifecycle apps can now request SwiftUI scenes.** This enables using SwiftUI-only scene types like `MenuBarExtra` and `ImmersiveSpace` from imperative lifecycle apps via `UIApplication.shared.activateSceneSession(for:errorHandler:)`.

> Source: "What's new in SwiftUI" (WWDC25, session 256)

---

## Quick Lookup Table

| Deprecated | Recommended | Since |
|-----------|-------------|-------|
| `navigationBarTitle(_:)` | `navigationTitle(_:)` | iOS 15+ |
| `navigationBarItems(...)` | `toolbar { ToolbarItem(...) }` | iOS 15+ |
| `navigationBarHidden(_:)` | `toolbarVisibility(.hidden, for: .navigationBar)` | iOS 15+ |
| `statusBar(hidden:)` | `statusBarHidden(_:)` | iOS 15+ |
| `edgesIgnoringSafeArea(_:)` | `ignoresSafeArea(_:edges:)` | iOS 15+ |
| `colorScheme(_:)` | `preferredColorScheme(_:)` | iOS 15+ |
| `foregroundColor(_:)` | `foregroundStyle(_:)` | iOS 15+ |
| `cornerRadius(_:)` | `clipShape(.rect(cornerRadius:))` | iOS 15+ |
| `actionSheet(...)` | `confirmationDialog(...)` | iOS 15+ |
| `alert(isPresented:content:)` | `alert(_:isPresented:actions:message:)` | iOS 15+ |
| `autocapitalization(_:)` | `textInputAutocapitalization(_:)` | iOS 15+ |
| `accessibility(label:)` etc. | `accessibilityLabel()` etc. | iOS 15+ |
| `TextField` `onCommit`/`onEditingChanged` | `onSubmit` + `focused` | iOS 15+ |
| `animation(_:)` (no value) | `animation(_:value:)` | Back-deploys (iOS 13+) |
| `Section(header:content:)` | `Section(content:header:)` | Future-deprecated |
| `Section(footer:content:)` | `Section(content:footer:)` | Future-deprecated |
| `Section(header:footer:content:)` | `Section(content:header:footer:)` | Future-deprecated |
| Manual `EnvironmentKey` | `@Entry` macro | Back-deploys (Xcode 16+) |
| `NavigationView` | `NavigationStack` / `NavigationSplitView` | iOS 16+ |
| `accentColor(_:)` | `tint(_:)` | iOS 16+ |
| `disableAutocorrection(_:)` | `autocorrectionDisabled(_:)` | iOS 16+ |
| `UIPasteboard.general` | `PasteButton` | iOS 16+ |
| `onChange(of:perform:)` | `onChange(of:) { }` or `onChange(of:) { old, new in }` | iOS 17+ |
| `UIImpactFeedbackGenerator` / `UISelectionFeedbackGenerator` / `UINotificationFeedbackGenerator` | `sensoryFeedback(_:trigger:)` | iOS 17+ |
| `MagnificationGesture` | `MagnifyGesture` | iOS 17+ |
| `RotationGesture` | `RotateGesture` | iOS 17+ |
| `coordinateSpace(name:)` | `coordinateSpace(.named(...))` | iOS 17+ |
| `ObservableObject` | `@Observable` | iOS 17+ |
| `tabItem(_:)` | `Tab` API | iOS 18+ |
| Manual `animatableData` | `@Animatable` macro | iOS 26+ |
| `presentationBackground(_:)` on sheets | Default Liquid Glass sheet material | iOS 26+ |
| Custom toolbar background hacks | `scrollEdgeEffectStyle(_:for:)` | iOS 26+ |
references/layout-best-practices.md
# SwiftUI Layout Best Practices Reference

## Table of Contents

- [Relative Layout Over Constants](#relative-layout-over-constants)
- [Context-Agnostic Views](#context-agnostic-views)
- [Own Your Container](#own-your-container)
- [Layout Performance](#layout-performance)
- [View Logic and Testability](#view-logic-and-testability)
- [Full-Width Views](#full-width-views)
- [Action Handlers](#action-handlers)
- [Summary Checklist](#summary-checklist)

## Relative Layout Over Constants

**Use dynamic layout calculations instead of hard-coded values.**

```swift
// Good - relative to actual layout
GeometryReader { geometry in
    VStack {
        HeaderView()
            .frame(height: geometry.size.height * 0.2)
        ContentView()
    }
}

// Avoid - magic numbers that don't adapt
VStack {
    HeaderView()
        .frame(height: 150)  // Doesn't adapt to different screens
    ContentView()
}
```

**Why**: Hard-coded values don't account for different screen sizes, orientations, or dynamic content (like status bars during phone calls).

## Context-Agnostic Views

**Views should work in any context.** Never assume presentation style or screen size.

```swift
// Good - adapts to given space
struct ProfileCard: View {
    let user: User
    
    var body: some View {
        VStack {
            Image(user.avatar)
                .resizable()
                .aspectRatio(contentMode: .fit)
            Text(user.name)
            Spacer()
        }
        .padding()
    }
}

// Avoid - assumes full screen
struct ProfileCard: View {
    let user: User
    
    var body: some View {
        VStack {
            Image(user.avatar)
                .frame(width: UIScreen.main.bounds.width)  // Wrong!
            Text(user.name)
        }
    }
}
```

**Why**: Views should work as full screens, modals, sheets, popovers, or embedded content.

## Own Your Container

**Custom views should own static containers but not lazy/repeatable ones.**

```swift
// Good - owns static container
struct HeaderView: View {
    var body: some View {
        HStack {
            Image(systemName: "star")
            Text("Title")
            Spacer()
        }
    }
}

// Avoid - missing container
struct HeaderView: View {
    var body: some View {
        Image(systemName: "star")
        Text("Title")
        // Caller must wrap in HStack
    }
}

// Good - caller owns lazy container
struct FeedView: View {
    let items: [Item]
    
    var body: some View {
        LazyVStack {
            ForEach(items) { item in
                ItemRow(item: item)
            }
        }
    }
}
```

## Layout Performance

### Avoid Layout Thrash

**Minimize deep view hierarchies and excessive layout dependencies.**

```swift
// Bad - deep nesting, excessive layout passes
VStack {
    HStack {
        VStack {
            HStack {
                VStack {
                    Text("Deep")
                }
            }
        }
    }
}

// Good - flatter hierarchy
VStack {
    Text("Shallow")
    Text("Structure")
}
```

**Avoid excessive `GeometryReader` and preference chains:**

```swift
// Bad - multiple geometry readers cause layout thrash
GeometryReader { outerGeometry in
    VStack {
        GeometryReader { innerGeometry in
            // Layout recalculates multiple times
        }
    }
}

// Good - single geometry reader or use alternatives (iOS 17+)
containerRelativeFrame(.horizontal) { width, _ in
    width * 0.8
}
```

**Gate frequent geometry updates:**

```swift
// Bad - updates on every pixel change
.onPreferenceChange(ViewSizeKey.self) { size in
    currentSize = size
}

// Good - gate by threshold
.onPreferenceChange(ViewSizeKey.self) { size in
    let difference = abs(size.width - currentSize.width)
    if difference > 10 {  // Only update if significant change
        currentSize = size
    }
}
```

## View Logic and Testability

### Keep Business Logic in Services and Models

**Business logic belongs in services and models, not in views.** Views should stay simple and declarative — orchestrating UI state, not implementing business rules. This makes logic independently testable without requiring view instantiation.

> **iOS 17+**: Use `@Observable` with `@State`.

```swift
@Observable
final class AuthService {
    var email = ""
    var password = ""
    var isValid: Bool {
        !email.isEmpty && password.count >= 8
    }

    func login() async throws {
        // Business logic here — testable without the view
    }
}

struct LoginView: View {
    @State private var authService = AuthService()

    var body: some View {
        Form {
            TextField("Email", text: $authService.email)
            SecureField("Password", text: $authService.password)
            Button("Login") {
                Task {
                    try? await authService.login()
                }
            }
            .disabled(!authService.isValid)
        }
    }
}
```

For iOS 16 and earlier, use `ObservableObject` with `@StateObject` -- see `state-management.md` for the legacy pattern.

Avoid embedding business logic directly in view closures (e.g., validation checks inside a `Button` action). This makes logic untestable without view instantiation.

**Note**: This is about making business logic testable, not about enforcing a specific architecture. The key is that logic lives outside views where it can be tested independently.

## Full-Width Views

**When a single view needs to fill the available width, use `.frame(maxWidth: .infinity, alignment:)` instead of wrapping it in a stack with a `Spacer`.**

```swift
// Good - frame modifier
Text("Hello")
    .frame(maxWidth: .infinity, alignment: .leading)

// Avoid - unnecessary stack and spacer
HStack {
    Text("Hello")
    Spacer()
}
```

**Why**: `.frame(maxWidth:alignment:)` is a single modifier that clearly communicates intent. Wrapping in an `HStack` with a `Spacer` adds an extra container to the view hierarchy for no benefit.

## Action Handlers

**Separate layout from logic.** View body should reference action methods, not contain inline logic.

```swift
// Good - action references method
Button("Publish Project", action: publishService.handlePublish)

// Avoid - multi-line logic in closure
Button("Publish Project") {
    isLoading = true
    apiService.publish(project) { result in /* ... */ }
}
```

## Summary Checklist

- [ ] Use relative layout over hard-coded constants
- [ ] Views work in any context (don't assume screen size)
- [ ] Custom views own static containers
- [ ] Avoid deep view hierarchies (layout thrash)
- [ ] Gate frequent geometry updates by thresholds
- [ ] Business logic kept in services and models (not in views)
- [ ] Action handlers reference methods, not inline logic
- [ ] Use `.frame(maxWidth: .infinity, alignment:)` for full-width views (not `HStack` + `Spacer`)
- [ ] Avoid excessive `GeometryReader` usage
- [ ] Use `containerRelativeFrame()` when appropriate
references/liquid-glass.md
# SwiftUI Liquid Glass Reference (iOS 26+)

## Table of Contents

- [Overview](#overview)
- [Availability](#availability)
- [Core APIs](#core-apis)
- [GlassEffectContainer](#glasseffectcontainer)
- [Glass Button Styles](#glass-button-styles)
- [Morphing Transitions](#morphing-transitions)
- [Modifier Order](#modifier-order)
- [Complete Examples](#complete-examples)
- [Fallback Strategies](#fallback-strategies)
- [Design System Notes](#design-system-notes)
- [Best Practices](#best-practices)
- [Checklist](#checklist)

## Overview

Liquid Glass is Apple's new design language introduced in iOS 26. It provides translucent, dynamic surfaces that respond to content and user interaction. This reference covers the native SwiftUI APIs for implementing Liquid Glass effects.

**Only adopt Liquid Glass when explicitly requested by the user.** Do not proactively convert existing UI to glass effects.

## Availability

All Liquid Glass APIs require iOS 26 or later. Always provide fallbacks:

```swift
if #available(iOS 26, *) {
    // Liquid Glass implementation
} else {
    // Fallback using materials
}
```

## Core APIs

### glassEffect Modifier

The primary modifier for applying glass effects to views:

```swift
.glassEffect(_ glass: Glass = .regular, in shape: some Shape = .rect, isEnabled: Bool = true)
```

#### Basic Usage

```swift
Text("Hello")
    .padding()
    .glassEffect()  // Default regular style, rect shape
```

#### With Shape

```swift
Text("Rounded Glass")
    .padding()
    .glassEffect(in: .rect(cornerRadius: 16))

Image(systemName: "star")
    .padding()
    .glassEffect(in: .circle)

Text("Capsule")
    .padding(.horizontal, 20)
    .padding(.vertical, 10)
    .glassEffect(in: .capsule)
```

### Glass

#### Available Styles

The `Glass` type exposes three static values — there is no `.prominent`:

```swift
.glassEffect(.regular)   // Standard glass appearance (most common)
.glassEffect(.clear)     // Nearly invisible glass surface
.glassEffect(.identity)  // No-op / pass-through glass
```

To make a surface appear more prominent, increase the tint opacity instead of reaching for a non-existent `.prominent` property.

#### Tinting

Add color tint to the glass:

```swift
.glassEffect(.regular.tint(.blue))
.glassEffect(.regular.tint(.red.opacity(0.3)))
```

#### Interactivity

Make glass respond to touch/pointer hover:

```swift
// Interactive glass - responds to user interaction
.glassEffect(.regular.interactive())

// Combined with tint
.glassEffect(.regular.tint(.blue).interactive())
```

**Important**: Only use `.interactive()` on elements that actually respond to user input (buttons, tappable views, focusable elements).

## GlassEffectContainer

Wraps multiple glass elements for proper visual grouping and spacing.

**Glass cannot sample other glass.** The glass material reflects and refracts light by sampling content from an area larger than itself. Nearby glass elements in different containers will produce inconsistent visual results because they cannot sample each other. `GlassEffectContainer` gives grouped elements a shared sampling region, ensuring a consistent appearance.

```swift
GlassEffectContainer {
    HStack {
        Button("One") { }
            .glassEffect()
        Button("Two") { }
            .glassEffect()
    }
}
```

### With Spacing

Control the visual spacing between glass elements:

```swift
GlassEffectContainer(spacing: 24) {
    HStack(spacing: 24) {
        GlassChip(icon: "pencil")
        GlassChip(icon: "eraser")
        GlassChip(icon: "trash")
    }
}
```

**Note**: The container's `spacing` parameter should match the actual spacing in your layout for proper glass effect rendering.

> Source: "Build a SwiftUI app with the new design" (WWDC25, session 323)

## Glass Button Styles

Built-in button styles for glass appearance:

```swift
// Standard glass button
Button("Action") { }
    .buttonStyle(.glass)

// Prominent glass button (higher visibility)
Button("Primary Action") { }
    .buttonStyle(.glassProminent)
```

### Custom Glass Buttons

For more control, apply glass effect manually:

```swift
Button(action: { }) {
    Label("Settings", systemImage: "gear")
        .padding()
}
.glassEffect(.regular.interactive(), in: .capsule)
```

## Morphing Transitions

Create smooth transitions between glass elements using `glassEffectID` and `@Namespace`:

```swift
struct MorphingExample: View {
    @Namespace private var animation
    @State private var isExpanded = false

    var body: some View {
        GlassEffectContainer {
            if isExpanded {
                ExpandedCard()
                    .glassEffect()
                    .glassEffectID("card", in: animation)
            } else {
                CompactCard()
                    .glassEffect()
                    .glassEffectID("card", in: animation)
            }
        }
        .animation(.smooth, value: isExpanded)
    }
}
```

### Requirements for Morphing

1. Both views must have the same `glassEffectID`
2. Use the same `@Namespace`
3. Wrap in `GlassEffectContainer`
4. Apply animation to the container or parent

## Modifier Order

**Critical**: Apply `glassEffect` after layout and visual modifiers:

```swift
// CORRECT order
Text("Label")
    .font(.headline)           // 1. Typography
    .foregroundStyle(.primary) // 2. Color
    .padding()                 // 3. Layout
    .glassEffect()             // 4. Glass effect LAST

// WRONG order - glass applied too early
Text("Label")
    .glassEffect()             // Wrong position
    .padding()
    .font(.headline)
```

## Complete Examples

### Toolbar with Glass Buttons

```swift
struct GlassToolbar: View {
    var body: some View {
        if #available(iOS 26, *) {
            GlassEffectContainer(spacing: 16) {
                HStack(spacing: 16) {
                    ToolbarButton(icon: "pencil", action: { })
                    ToolbarButton(icon: "eraser", action: { })
                    ToolbarButton(icon: "scissors", action: { })
                    Spacer()
                    ToolbarButton(icon: "square.and.arrow.up", action: { })
                }
                .padding(.horizontal)
            }
        } else {
            // Fallback toolbar
            HStack(spacing: 16) {
                // ... fallback implementation
            }
        }
    }
}

struct ToolbarButton: View {
    let icon: String
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Image(systemName: icon)
                .font(.title2)
                .frame(width: 44, height: 44)
        }
        .glassEffect(.regular.interactive(), in: .circle)
    }
}
```

### Card with Glass Effect

```swift
struct GlassCard: View {
    let title: String
    let subtitle: String

    var body: some View {
        if #available(iOS 26, *) {
            cardContent
                .glassEffect(.regular, in: .rect(cornerRadius: 20))
        } else {
            cardContent
                .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
        }
    }

    private var cardContent: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(title)
                .font(.headline)
            Text(subtitle)
                .font(.subheadline)
                .foregroundStyle(.secondary)
        }
        .padding()
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}
```

### Segmented Control

```swift
struct GlassSegmentedControl: View {
    @Binding var selection: Int
    let options: [String]
    @Namespace private var animation

    var body: some View {
        if #available(iOS 26, *) {
            GlassEffectContainer(spacing: 4) {
                HStack(spacing: 4) {
                    ForEach(options.indices, id: \.self) { index in
                        Button(options[index]) {
                            withAnimation(.smooth) {
                                selection = index
                            }
                        }
                        .padding(.horizontal, 16)
                        .padding(.vertical, 8)
                        .glassEffect(
                            selection == index
                                ? .regular.tint(.accentColor.opacity(0.4)).interactive()
                                : .regular.interactive(),
                            in: .capsule
                        )
                        .glassEffectID(selection == index ? "selected" : "option\(index)", in: animation)
                    }
                }
                .padding(4)
            }
        } else {
            Picker("Options", selection: $selection) {
                ForEach(options.indices, id: \.self) { index in
                    Text(options[index]).tag(index)
                }
            }
            .pickerStyle(.segmented)
        }
    }
}
```

## Fallback Strategies

### Using Materials

```swift
if #available(iOS 26, *) {
    content.glassEffect()
} else {
    content.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
```

### Available Materials for Fallback

- `.ultraThinMaterial` - Closest to glass appearance
- `.thinMaterial` - Slightly more opaque
- `.regularMaterial` - Standard blur
- `.thickMaterial` - More opaque
- `.ultraThickMaterial` - Most opaque

### Conditional Modifier Extension

```swift
extension View {
    @ViewBuilder
    func glassEffectWithFallback(
        _ glass: Glass = .regular,
        in shape: some Shape = .rect,
        fallbackMaterial: Material = .ultraThinMaterial
    ) -> some View {
        if #available(iOS 26, *) {
            self.glassEffect(glass, in: shape)
        } else {
            self.background(fallbackMaterial, in: shape)
        }
    }
}
```

## Design System Notes

### Toolbar Icons

In the new design, toolbar icons use **monochrome rendering** by default. The monochrome palette reduces visual noise and maintains legibility. Use `tint(_:)` only to convey meaning (e.g., a call to action), not for visual effect.

### Sheet Presentations

Partial-height sheets use a Liquid Glass background by default. If you previously used `presentationBackground(_:)` with a custom background, consider removing it to let the new material shine. Sheets can morph out of the glass controls that present them using `navigationZoomTransition`.

### Scroll Edge Effects

An automatic scroll edge effect blurs and fades content under system toolbars to keep controls legible. Remove any custom background-darkening effects behind bar items, as they will interfere.

> Source: "Build a SwiftUI app with the new design" (WWDC25, session 323)

## Best Practices

### Do

- Use `GlassEffectContainer` for grouped glass elements (glass cannot sample other glass)
- Apply glass after layout modifiers
- Use `.interactive()` only on tappable elements
- Match container spacing with layout spacing
- Provide material-based fallbacks for older iOS
- Keep glass shapes consistent within a feature
- Remove custom `presentationBackground(_:)` on sheets to use the default glass material

### Don't

- Apply glass to every element (use sparingly)
- Use `.interactive()` on static content
- Mix different corner radii arbitrarily
- Forget iOS version checks
- Apply glass before padding/frame modifiers
- Nest `GlassEffectContainer` unnecessarily
- Add custom darkening backgrounds behind toolbars (conflicts with scroll edge effect)

## Checklist

- [ ] `#available(iOS 26, *)` with fallback
- [ ] `GlassEffectContainer` wraps grouped elements
- [ ] `.glassEffect()` applied after layout modifiers
- [ ] `.interactive()` only on user-interactable elements
- [ ] `glassEffectID` with `@Namespace` for morphing
- [ ] Consistent shapes and spacing across feature
- [ ] Container spacing matches layout spacing
- [ ] Tint opacity used instead of non-existent `.prominent` for emphasis
references/list-patterns.md
# SwiftUI List Patterns Reference

## Table of Contents

- [ForEach Identity and Stability](#foreach-identity-and-stability)
- [Enumerated Sequences](#enumerated-sequences)
- [List with Custom Styling](#list-with-custom-styling)
- [List with Pull-to-Refresh](#list-with-pull-to-refresh)
- [Empty States with ContentUnavailableView (iOS 17+)](#empty-states-with-contentunavailableview-ios-17)
- [Custom List Backgrounds](#custom-list-backgrounds)
- [Table](#table)
- [Summary Checklist](#summary-checklist)

## ForEach Identity and Stability

**Always provide stable identity for `ForEach`.** Never use `.indices` for dynamic content.

```swift
// Good - stable identity via Identifiable
extension User: Identifiable {
    var id: String { userId }
}

ForEach(users) { user in
    UserRow(user: user)
}

// Good - stable identity via keypath
ForEach(users, id: \.userId) { user in
    UserRow(user: user)
}

// Wrong - indices create static content
ForEach(users.indices, id: \.self) { index in
    UserRow(user: users[index])  // Can crash on removal!
}

// Wrong - unstable identity
ForEach(users, id: \.self) { user in
    UserRow(user: user)  // Only works if User is Hashable and stable
}
```

**Critical**: Ensure **constant number of views per element** in `ForEach`:

```swift
// Good - consistent view count
ForEach(items) { item in
    ItemRow(item: item)
}

// Bad - variable view count breaks identity
ForEach(items) { item in
    if item.isSpecial {
        SpecialRow(item: item)
        DetailRow(item: item)
    } else {
        RegularRow(item: item)
    }
}
```

**Avoid inline filtering:**

```swift
// Bad - unstable identity, changes on every update
ForEach(items.filter { $0.isEnabled }) { item in
    ItemRow(item: item)
}

// Good - prefilter and cache
@State private var enabledItems: [Item] = []

var body: some View {
    ForEach(enabledItems) { item in
        ItemRow(item: item)
    }
    .onChange(of: items) { _, newItems in
        enabledItems = newItems.filter { $0.isEnabled }
    }
}
```

**Avoid `AnyView` in list rows:**

```swift
// Bad - hides identity, increases cost
ForEach(items) { item in
    AnyView(item.isSpecial ? SpecialRow(item: item) : RegularRow(item: item))
}

// Good - Create a unified row view with a single top-level container
ForEach(items) { item in
    ItemRow(item: item)
}

struct ItemRow: View {
    let item: Item

    var body: some View {
        // The VStack keeps the row "unary" (one top-level view) so the
        // List can template row ids without evaluating every row's body.
        VStack {
            if item.isSpecial {
                SpecialRow(item: item)
            } else {
                RegularRow(item: item)
            }
        }
    }
}
```

**Why**: Stable identity is critical for performance and animations. Unstable identity causes excessive diffing, broken animations, and potential crashes.

### Prefer unary rows in `List`

`List` needs the identity of every row up front. When each row's body produces a **single top-level view** (a "unary" row), SwiftUI can template the row id from the `ForEach` element's id alone, without running each row's `body`. When the body branches between different top-level shapes — a bare top-level `switch`, a top-level `if` without `else`, or an `AnyView` — structural identity varies per row, so SwiftUI falls back to evaluating every row's body just to compute ids. That cost scales with the number of rows.

The fix is to wrap branching content in any single-root container (`VStack`, `HStack`, `ZStack`, or a custom wrapper) so the row is always exactly one top-level view, as shown above. A top-level `if` without an `else` is also "multi" (0 or 1 views); if some elements shouldn't be rows at all, filter the collection before it reaches the `ForEach` rather than producing a zero-view row.

To find non-constant row builders in an existing app, launch with `-LogForEachSlowPath YES`; SwiftUI logs each `ForEach` inside a lazy container whose row body produces a non-constant number of views.

### Keep ids stable, unique, and cheap

Three more identity rules that prevent subtle bugs:

- **The id must outlive the view and not change on edit.** Don't derive `id` from a mutable property (e.g. `var id: String { title }`). Editing the title changes the id, so SwiftUI treats it as a removal plus insertion — focus and per-row state are lost mid-edit. Use a stable `let id: UUID` or a server-assigned key.
- **Don't synthesize a fresh id inside `body`.** `ForEach(items.map { Item(title: $0) })` creates new `UUID`s on every body pass, so the whole collection reads as replaced every update. Create ids once in storage that outlives `body` (the model layer), not inline.
- **Keep the id cheap to hash.** Avoid `id: \.self` on a large `Hashable` struct; hashing walks every field on every diff. Use a small primitive (`UUID`, `Int`, short `String`, `URL`) and still pass the full element to the row.

### Identifiable ID Must Be Truly Unique

Non-unique IDs cause SwiftUI to treat different items as identical, leading to duplicate rendering or missing views:

```swift
// Bug -- two articles with the same URL show identical content
struct Article: Identifiable {
    let title: String
    let url: URL
    var id: String { url.absoluteString }  // Not unique if URLs repeat!
}

// Fix -- use a genuinely unique identifier
struct Article: Identifiable {
    let id: UUID
    let title: String
    let url: URL
}
```

**Classes get a default `ObjectIdentifier`-based `id`** when conforming to `Identifiable` without providing one. This is only unique for the object's lifetime and can be recycled after deallocation.

## Enumerated Sequences

**Using `.enumerated()` is fine; the index just must not be the identity.** Using `\.offset` as the id is the same anti-pattern as `\.self` on `items.indices` — the id becomes the position, not the element, so inserts and reorders reset row state and break animations. Keep the element's own identity as the id and treat the index as ordinary row data.

```swift
// Wrong - offset is the position, not the element
ForEach(items.enumerated(), id: \.offset) { index, item in
    ItemRow(number: index + 1, item: item)
}

// Correct - id comes from the element; index is just data
ForEach(items.enumerated(), id: \.element.id) { index, item in
    ItemRow(number: index + 1, item: item)
}
```

**No `Array(...)` wrapper is needed on Swift 6.1+.** As of Swift 6.1, the sequence returned by `.enumerated()` conditionally conforms to `RandomAccessCollection` when the base collection does, so `ForEach` accepts it directly. On earlier toolchains, wrap it in `Array(...)`. Favor the direct form in new code — it avoids an eager copy on every body evaluation.

## List with Custom Styling

```swift
// Remove default background and separators
List(items) { item in
    ItemRow(item: item)
        .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
        .listRowSeparator(.hidden)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.customBackground)
.environment(\.defaultMinListRowHeight, 1)  // Allows custom row heights
```

## List with Pull-to-Refresh

```swift
List(items) { item in
    ItemRow(item: item)
}
.refreshable {
    await loadItems()
}
```

## Empty States with ContentUnavailableView (iOS 17+)

Use `ContentUnavailableView` for empty list/search states. The built-in `.search` variant is auto-localized:

```swift
List {
    ForEach(searchResults) { item in
        ItemRow(item: item)
    }
}
.overlay {
    if searchResults.isEmpty, !searchText.isEmpty {
        ContentUnavailableView.search(text: searchText)
    }
}
```

For non-search empty states, use a custom instance:

```swift
ContentUnavailableView(
    "No Articles",
    systemImage: "doc.richtext.fill",
    description: Text("Articles you save will appear here.")
)
```

## Custom List Backgrounds

Use `.scrollContentBackground(.hidden)` to replace the default list background:

```swift
List(items) { item in
    ItemRow(item: item)
}
.scrollContentBackground(.hidden)
.background(Color.customBackground)
```

Without `.scrollContentBackground(.hidden)`, a custom `.background()` has no visible effect on `List`.

## Table

> **Availability:** iOS 16.0+, iPadOS 16.0+, visionOS 1.0+

A multi-column data container that presents rows of `Identifiable` data with sortable, selectable columns. On compact size classes (iPhone, iPad Slide Over), columns after the first are automatically hidden.

### Basic Table

```swift
struct Person: Identifiable {
    let givenName: String
    let familyName: String
    let emailAddress: String
    let id = UUID()
    var fullName: String { givenName + " " + familyName }
}

struct PeopleTable: View {
    @State private var people: [Person] = [ /* ... */ ]

    var body: some View {
        Table(people) {
            TableColumn("Given Name", value: \.givenName)
            TableColumn("Family Name", value: \.familyName)
            TableColumn("E-Mail Address", value: \.emailAddress)
        }
    }
}
```

### Table with Selection

Bind to a single `ID` for single-selection, or a `Set<ID>` for multi-selection:

```swift
struct SelectableTable: View {
    @State private var people: [Person] = [ /* ... */ ]
    @State private var selectedPeople = Set<Person.ID>()

    var body: some View {
        Table(people, selection: $selectedPeople) {
            TableColumn("Given Name", value: \.givenName)
            TableColumn("Family Name", value: \.familyName)
            TableColumn("E-Mail Address", value: \.emailAddress)
        }
        Text("\(selectedPeople.count) people selected")
    }
}
```

### Sortable Table

Provide a binding to `[KeyPathComparator]` and re-sort the data in `.onChange(of:)`:

```swift
struct SortableTable: View {
    @State private var people: [Person] = [ /* ... */ ]
    @State private var sortOrder = [KeyPathComparator(\Person.givenName)]

    var body: some View {
        Table(people, sortOrder: $sortOrder) {
            TableColumn("Given Name", value: \.givenName)
            TableColumn("Family Name", value: \.familyName)
            TableColumn("E-Mail Address", value: \.emailAddress)
        }
        .onChange(of: sortOrder) { _, newOrder in
            people.sort(using: newOrder)
        }
    }
}
```

**Important:** The table does **not** sort data itself — you must re-sort the collection when `sortOrder` changes.

### Adaptive Table for Compact Size Classes

On iPhone or iPad in Slide Over, only the first column is shown. Customize it to display combined information:

```swift
struct AdaptiveTable: View {
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
    private var isCompact: Bool { horizontalSizeClass == .compact }

    @State private var people: [Person] = [ /* ... */ ]
    @State private var sortOrder = [KeyPathComparator(\Person.givenName)]

    var body: some View {
        Table(people, sortOrder: $sortOrder) {
            TableColumn("Given Name", value: \.givenName) { person in
                VStack(alignment: .leading) {
                    Text(isCompact ? person.fullName : person.givenName)
                    if isCompact {
                        Text(person.emailAddress)
                            .foregroundStyle(.secondary)
                    }
                }
            }
            TableColumn("Family Name", value: \.familyName)
            TableColumn("E-Mail Address", value: \.emailAddress)
        }
        .onChange(of: sortOrder) { _, newOrder in
            people.sort(using: newOrder)
        }
    }
}
```

### Table with Static Rows

Use `init(of:columns:rows:)` when rows are known at compile time:

```swift
struct Purchase: Identifiable {
    let price: Decimal
    let id = UUID()
}

struct TipTable: View {
    let currencyStyle = Decimal.FormatStyle.Currency(code: "USD")

    var body: some View {
        Table(of: Purchase.self) {
            TableColumn("Base price") { purchase in
                Text(purchase.price, format: currencyStyle)
            }
            TableColumn("With 15% tip") { purchase in
                Text(purchase.price * 1.15, format: currencyStyle)
            }
            TableColumn("With 20% tip") { purchase in
                Text(purchase.price * 1.2, format: currencyStyle)
            }
        } rows: {
            TableRow(Purchase(price: 20))
            TableRow(Purchase(price: 50))
            TableRow(Purchase(price: 75))
        }
    }
}
```

### Table with Dynamic Number of Columns

> **Availability:** iOS 17.4+, iPadOS 17.4+, Mac Catalyst 17.4+, macOS 14.4+, visionOS 1.1+

If the number of columns is not known at runtime use `TableColumnForEach` to create columns based on a `RandomAccessCollection` of some data type. Either the collection’s elements must conform to `Identifiable` or you need to provide an id parameter to the `TableColumnForEach` initializer.

This can be mixed with static compile time known `TableColumn` usage.

```swift
struct AudioChannel: Identifiable {
    let name: String
    let id: UUID
}

struct AudioSample: Identifiable {
    let id: UUID
    let timestamp: TimeInterval
    func level(channel: AudioChannel.ID) -> Double {
        1
    }
}

@Observable
class AudioSampleTrack {
    let channels: [AudioChannel]
    var samples: [AudioSample]
}

struct ContentView: View {
    var track: AudioSampleTrack

    var body: some View {
        Table(track.samples) {
            TableColumn("Timestamp (ms)") { sample in
                Text(sample.timestamp, format: .number.scale(1000))
                    .monospacedDigit()
            }
            TableColumnForEach(track.channels) { channel in
                TableColumn(channel.name) { sample in
                    Text(sample.level(channel: channel.id),
                         format: .number.precision(.fractionLength(2))
                    )
                    .monospacedDigit()
                }
                .width(ideal: 70)
                .alignment(.numeric)
            }
        }
    }
}
```

### Table Styles

```swift
// Inset (no borders)
Table(people) { /* columns */ }
    .tableStyle(.inset)

// Hide column headers
Table(people) { /* columns */ }
    .tableColumnHeaders(.hidden)
```

### Platform Behavior

| Platform | Behavior |
|----------|----------|
| **iPadOS (regular)** | Full multi-column layout; headers and all columns visible |
| **iPadOS (compact)** | Only the first column shown; headers hidden |
| **iPhone (all sizes)** | Only the first column shown; headers hidden; list-like appearance |

> **Best Practice:** Prefer handling the compact size class by showing combined info in the first column. This provides a seamless transition when the size class changes (e.g., entering/exiting Slide Over on iPad).

## Summary Checklist

- [ ] ForEach uses stable identity (never `.indices` or `\.offset` for dynamic content)
- [ ] Identifiable IDs are truly unique across all items
- [ ] id is stable across edits (not derived from a mutable property), created outside `body`, and cheap to hash
- [ ] Constant number of views per ForEach element; rows are unary (single top-level view)
- [ ] No inline filtering in ForEach (prefilter and cache instead)
- [ ] No `AnyView` in list rows
- [ ] `.enumerated()` uses the element's id (not `\.offset`); no `Array(...)` wrapper needed on Swift 6.1+
- [ ] Use `.refreshable` for pull-to-refresh
- [ ] Use `ContentUnavailableView` for empty states (iOS 17+)
- [ ] Use `.scrollContentBackground(.hidden)` for custom list backgrounds
- [ ] `Table` adapts for compact size classes (first column shows combined info)
- [ ] `Table` sorting re-sorts data in `.onChange(of: sortOrder)` (table doesn't sort itself)
- [ ] `Table` data conforms to `Identifiable`
references/localization.md
# SwiftUI Localization Reference

Guidance for user-facing text: `Text`, `Button`, `Label`, navigation/toolbar titles, alerts, and types that carry localizable strings. For the narrower "verbatim vs localized" decision on a single `Text`, see `references/text-patterns.md`.

## Table of Contents

- [SwiftUI Localizes String Literals Automatically](#swiftui-localizes-string-literals-automatically)
- [String Catalogs](#string-catalogs)
- [Bundle for Swift Packages and Frameworks](#bundle-for-swift-packages-and-frameworks)
- [Localizing Variables and Custom Types](#localizing-variables-and-custom-types)
- [LocalizedStringResource for Non-View Types](#localizedstringresource-for-non-view-types)
- [Interpolation vs Concatenation](#interpolation-vs-concatenation)
- [Casing](#casing)
- [Formatting Dates, Numbers, and Currencies](#formatting-dates-numbers-and-currencies)
- [Layout for Localization](#layout-for-localization)
- [Reading the Current Locale](#reading-the-current-locale)
- [String(localized:) Outside SwiftUI Views](#stringlocalized-outside-swiftui-views)
- [Comments for Translators](#comments-for-translators)

## SwiftUI Localizes String Literals Automatically

Initializers that accept `LocalizedStringKey` (`Text`, `Button`, `Label`, `.navigationTitle`, alert titles, and so on) treat string literals as localization keys automatically. Do not wrap literals in `NSLocalizedString`, `String(localized:)`, or `LocalizedStringResource` — that resolves the string eagerly and ignores `\.locale` overrides.

```swift
// AVOID: double work, and resolves eagerly
Text(String(localized: "start_workout"))

// PREFER: pass the literal directly
Text("start_workout")
```

Both opaque keys (`"start_workout"`) and natural-language strings (`"Start Workout"`) work as keys — pick whichever convention the project already uses. Use `Text(verbatim:)` only to opt a literal out of localization (e.g. a debug label interpolating a runtime value). When the argument is already a `String` variable, `Text(value)` calls the `StringProtocol` overload and skips localization on its own.

## String Catalogs

Most projects localize through String Catalogs (`.xcstrings`). Each build syncs new keys from code into the catalog, but the catalog file must already exist — Xcode doesn't create one automatically. If a project already uses `.strings` / `.stringsdict`, add new strings there rather than migrating. Route groups of strings to a specific catalog with `tableName:`.

```swift
Text("Explore", tableName: "Navigation",
     comment: "Tab bar item title for the Explore screen.")
```

## Bundle for Swift Packages and Frameworks

Apps, app extensions, and XPC services are their own main bundle, so `bundle` can be omitted. Frameworks and Swift packages need an explicit `bundle:` — without one, SwiftUI looks up strings in `Bundle.main`, the lookup fails silently, and the string appears unlocalized at runtime.

```swift
// AVOID (inside a framework/package): searches the app's catalog
Text("Save to Favorites")

// PREFER: #bundle resolves to the current target's bundle
Text("Save to Favorites", bundle: #bundle,
     comment: "Button to bookmark a recipe.")
```

`#bundle` is the preferred form; `Bundle.module` and `Bundle(for:)` still work but are older patterns.

## Localizing Variables and Custom Types

A `String` variable passed to `Text` runs the `StringProtocol` overload and is **not** localized. Wrapping it in `LocalizedStringKey(_:)` doesn't help — Xcode can't extract a literal from a runtime value, so nothing lands in the catalog. To localize a value chosen from a known set, model it with a type that exposes `LocalizedStringResource`:

```swift
enum Category {
    case appetizers, mains, desserts
    var name: LocalizedStringResource {
        switch self {
        case .appetizers: "Appetizers"
        case .mains: "Mains"
        case .desserts: "Desserts"
        }
    }
}

Text(category.name)
```

When a view or model exposes user-facing text, type the property as `LocalizedStringKey` or `LocalizedStringResource` rather than `String`. Every SwiftUI view that takes localized text accepts both, so deferring resolution costs nothing at the display site and preserves locale/bundle context.

## LocalizedStringResource for Non-View Types

When a non-view type carries user-facing text — a model object, a tip, a queued notification — use `LocalizedStringResource` instead of `String`. It defers resolution to display time, so it honors the locale active when the value actually renders, not when it was created.

```swift
// AVOID: resolved at creation time, can't re-render in another locale
struct Tip { let headline: String }
let tip = Tip(headline: String(localized: "Tip of the Day"))

// PREFER: resolution deferred to display time
struct Tip { let headline: LocalizedStringResource }
let tip = Tip(headline: "Tip of the Day")
```

Apply this when designing new types or changing user-facing text — don't sweep through existing `String` properties as part of unrelated edits.

## Interpolation vs Concatenation

String interpolation preserves `LocalizedStringKey` and produces a format string in the catalog (e.g. `"Welcome, %@"`). Concatenation with `+` produces a plain `String` and is not localized. Never glue separately localized fragments into a sentence — word order varies across languages.

```swift
// AVOID: + produces String; sentence assembly breaks word order
Text("Error: " + statusMessage)
Text(String(localized: "Created by")) + Text(" ") + Text(authorName)

// PREFER: one interpolated string translators can rearrange
Text("Error: \(statusMessage)")
Text("Created by \(authorName)")
```

## Casing

Bake the desired case into the string rather than transforming at runtime via `.textCase(_:)`, `.localizedUppercase`, or `.localizedCapitalized`. A runtime transform forces the same casing on every translation, leaving translators no room to adjust per language.

```swift
// AVOID
Text("Section Header").textCase(.uppercase)

// PREFER
Text("SECTION HEADER")
```

This applies to localized strings; display user-entered text as-is. If a transform is unavoidable, prefer `.localizedUppercase` / `.localizedCapitalized`, which honor the user's locale.

## Formatting Dates, Numbers, and Currencies

Use `Text`'s `format:` parameter or `.formatted()` instead of `DateFormatter` / `NumberFormatter` with hardcoded format strings. Format styles adapt to the user's locale; hardcoded format strings don't.

```swift
// AVOID
let f = DateFormatter(); f.dateFormat = "MM/dd/yyyy"
Text(f.string(from: workout.date))
Text("$\(product.price, specifier: "%.2f")")

// PREFER
Text(workout.date, format: .dateTime.month().day().year())
Text(product.price, format: .currency(code: store.currencyCode))
```

Field components (`.month()`, `.day()`) choose which fields appear; the locale decides the order. For lists, `Array.formatted()` inserts locale-correct separators and conjunctions instead of `joined(separator:)`. When `DateFormatter` is genuinely unavoidable, use `setLocalizedDateFormatFromTemplate(_:)` rather than assigning `dateFormat`.

## Layout for Localization

- Use `.leading` / `.trailing` instead of `.left` / `.right` — they flip for right-to-left locales.
- Don't hardcode frame widths/heights for text; translations vary in length and scripts vary in height. Use `ViewThatFits` when a layout might not fit longer translations.
- Use text styles (`.body`, `.headline`) rather than fixed point sizes, so line height adapts per script.

```swift
// PREFER
Text(recipe.title)
    .frame(maxWidth: .infinity, alignment: .leading)

ViewThatFits {
    HStack { actionButtons }
    VStack { actionButtons }
}
```

## Reading the Current Locale

Use `@Environment(\.locale)` for locale-dependent logic in views, not `Locale.current` — the environment respects preview overrides and per-view injection.

## String(localized:) Outside SwiftUI Views

When you need a localized `String` outside a view, use `String(localized:)`, not `NSLocalizedString`. Don't interpolate inside `NSLocalizedString` — Xcode extracts keys from literals at build time and can't extract interpolated values. `String(localized:)` supports interpolation (it extracts the format string and treats values as runtime arguments) and is preferred over `String(format:)`, which always renders digits as 0–9 regardless of locale.

```swift
// PREFER
let title = String(localized: "activity_summary", comment: "Dashboard header")
```

## Comments for Translators

Add a `comment:` describing the UI element and its purpose, especially for ambiguous strings. For interpolated strings, describe each placeholder by position — translators don't see Swift variable names. Comments can live at the call site or in the String Catalog's per-string Comment field; keep one source of truth per string.

```swift
// AVOID: "Edit" could be a noun or a verb
Text("Edit")

// PREFER
Text("Edit", comment: "Toolbar button that enters editing mode for the list.")
Text("Completed \(count) of \(total)",
     comment: "Progress label — first variable is finished items, second is the total.")
```

## Summary Checklist

- [ ] String literals passed directly to `Text`/`Button`/`Label` (not wrapped in `NSLocalizedString`/`String(localized:)`)
- [ ] `bundle: #bundle` on user-facing strings inside frameworks and Swift packages
- [ ] User-facing text on models/non-view types typed as `LocalizedStringResource`, not `String`
- [ ] Interpolation (not `+`) for dynamic strings; no sentence assembly from fragments
- [ ] Case baked into the string, not applied via `.textCase`
- [ ] Dates/numbers/currencies use `format:` / `.formatted()` with locale-aware styles
- [ ] `.leading`/`.trailing` (not `.left`/`.right`); no hardcoded text frame sizes
- [ ] `@Environment(\.locale)` for locale logic in views
- [ ] `comment:` provided for ambiguous strings and interpolated placeholders
references/macos-scenes.md
# macOS Scenes Reference

> SwiftUI scene types for macOS apps — `Settings`, `MenuBarExtra`, `WindowGroup`, `Window`, `UtilityWindow`, and `DocumentGroup`. Covers macOS-only scenes and cross-platform scenes with macOS-specific behavior.

## Table of Contents

- [Quick Lookup Table](#quick-lookup-table)
- [Settings (macOS-only)](#settings-macos-only)
- [MenuBarExtra (macOS-only)](#menubarextra-macos-only)
- [WindowGroup (macOS behavior)](#windowgroup-macos-behavior)
- [Window](#window)
- [UtilityWindow (macOS-only)](#utilitywindow-macos-only)
- [DocumentGroup](#documentgroup)
- [Platform Conditionals](#platform-conditionals)
- [Best Practices](#best-practices)

---

## Quick Lookup Table

| API | Availability | macOS-Only? | macOS-Specific Behavior |
|-----|-------------|:-----------:|------------------------|
| `WindowGroup` | macOS 11.0+ | No | Multiple window instances, tabbed interface, automatic Window menu commands |
| `Window` | macOS 13.0+ | No | App quits when sole window closes; adds itself to Windows menu |
| `UtilityWindow` | macOS 15.0+ | Yes | Floating tool palette; receives `FocusedValues` from active main window |
| `Settings` | macOS 11.0+ | Yes | Presents preferences window (Cmd+,) |
| `MenuBarExtra` | macOS 13.0+ | Yes | Persistent icon/menu in the system menu bar |
| `DocumentGroup` | macOS 11.0+ | No | Document-based menu bar commands (File > New/Open/Save); multiple document windows |

---

## Settings (macOS-only)

Presents the app's preferences window, accessible via **Cmd+,** or the app menu. SwiftUI automatically enables the Settings menu item and manages the window lifecycle.

```swift
Settings {
    TabView {
        Tab("General", systemImage: "gear") { GeneralSettingsView() }
        Tab("Advanced", systemImage: "star") { AdvancedSettingsView() }
    }
    .scenePadding()
    .frame(maxWidth: 350, minHeight: 100)
}
```

Use `TabView` with `Tab` items for multi-pane preferences. Each tab's content is typically a `Form` with `@AppStorage`-backed controls.

### SettingsLink (macOS 14.0+)

A button that opens the Settings scene. Use for in-app navigation to preferences.

```swift
struct SidebarFooter: View {
    var body: some View {
        SettingsLink {
            Label("Preferences", systemImage: "gear")
        }
    }
}
```

### openSettings environment action (macOS 14.0+)

Programmatically open (or bring to front) the Settings window.

```swift
struct OpenSettingsButton: View {
    @Environment(\.openSettings) private var openSettings

    var body: some View {
        Button("Open Settings") {
            openSettings()
        }
    }
}
```

---

## MenuBarExtra (macOS-only)

Renders a persistent control in the system menu bar. Two styles available:
- **`.menu`** (default) — standard dropdown menu
- **`.window`** — popover panel with custom SwiftUI views

### Menu-style (dropdown)

```swift
MenuBarExtra("My Utility", systemImage: "hammer") {
    Button("Action One") { /* ... */ }
    Button("Action Two") { /* ... */ }
    Divider()
    Button("Quit") { NSApplication.shared.terminate(nil) }
}
```

### Window-style (popover panel)

```swift
MenuBarExtra("Status", systemImage: "chart.bar") {
    DashboardView()
        .frame(width: 240)
}
.menuBarExtraStyle(.window)
```

**Variations:**
- **Toggleable** — pass `isInserted:` with an `@AppStorage` binding to let users show/hide the extra: `MenuBarExtra("Status", systemImage: "chart.bar", isInserted: $showMenuBarExtra)`
- **Menu-bar-only app** — use `MenuBarExtra` as the sole scene + set `LSUIElement = true` in Info.plist to hide the Dock icon. The app auto-terminates if the user removes the extra from the menu bar.

---

## WindowGroup (macOS behavior)

On macOS, `WindowGroup` supports:
- **Multiple window instances** — users can open many windows from File > New Window
- **Tabbed interface** — users can merge windows into tabs
- **Automatic Window menu** — commands for window management appear automatically

```swift
@main
struct Mail: App {
    var body: some Scene {
        // Basic multi-window support
        WindowGroup {
            MailViewer()
        }

        // Data-presenting window opened programmatically
        WindowGroup("Message", for: Message.ID.self) { $messageID in
            MessageDetail(messageID: messageID)
        }
    }
}

// Open a specific window programmatically
struct NewMessageButton: View {
    var message: Message
    @Environment(\.openWindow) private var openWindow

    var body: some View {
        Button("Open Message") {
            openWindow(value: message.id)
        }
    }
}
```

> **Key difference from `Window`:** `WindowGroup` keeps the app running even after all windows are closed. `Window` (as sole scene) quits the app when closed.

---

## Window

A single, unique window scene. The system ensures only one instance exists.

```swift
@main
struct Mail: App {
    var body: some Scene {
        WindowGroup {
            MailViewer()
        }

        // Supplementary singleton window
        Window("Connection Doctor", id: "connection-doctor") {
            ConnectionDoctor()
        }
    }
}

// Open programmatically — brings to front if already open
struct OpenDoctorButton: View {
    @Environment(\.openWindow) private var openWindow

    var body: some View {
        Button("Connection Doctor") {
            openWindow(id: "connection-doctor")
        }
    }
}
```

### Window as sole scene

If `Window` is the only scene, the app quits when the window closes:

```swift
@main
struct VideoCall: App {
    var body: some Scene {
        Window("VideoCall", id: "main") {
            CameraView()
        }
    }
}
```

> **Recommendation:** In most cases, prefer `WindowGroup` for the primary scene. Use `Window` for supplementary singleton windows.

---

## UtilityWindow (macOS-only)

A specialized floating window for tool palettes and inspector panels. Available since macOS 15.0.

**Key behaviors:**
- Receives `FocusedValues` from the focused main scene (like menu bar commands)
- Floats above main windows (default level: `.floating`)
- Hides when the app is no longer active
- Only becomes focused when explicitly needed (e.g., clicking the title bar)
- Dismissible with the Escape key
- Not minimizable by default
- Automatically adds a show/hide item to the View menu

```swift
@main
struct PhotoBrowser: App {
    var body: some Scene {
        WindowGroup {
            PhotoGallery()
        }

        UtilityWindow("Photo Info", id: "photo-info") {
            PhotoInfoViewer()
        }
    }
}

struct PhotoInfoViewer: View {
    // Automatically updates based on whichever main window is focused
    @FocusedValue(PhotoSelection.self) private var selectedPhotos

    var body: some View {
        if let photos = selectedPhotos {
            Text("\(photos.count) photos selected")
        } else {
            Text("No selection")
                .foregroundStyle(.secondary)
        }
    }
}
```

> **Tip:** Remove the automatic View menu item with `.commandsRemoved()` and place a `WindowVisibilityToggle` elsewhere in your commands.

---

## DocumentGroup

Document-based apps with automatic file management. On macOS, provides:
- **Document-based menu bar commands** (File > New, Open, Save, Revert)
- **Multiple document windows** simultaneously
- On iOS, shows a document browser instead

```swift
DocumentGroup(newDocument: TextFile()) { config in
    ContentView(document: config.$document)
}
```

The document type must conform to `FileDocument` (value type) or `ReferenceFileDocument` (reference type). Key requirements:

```swift
struct TextFile: FileDocument {
    static var readableContentTypes: [UTType] { [.plainText] }
    var text: String = ""
    init() {}
    init(configuration: ReadConfiguration) throws {
        text = String(data: configuration.file.regularFileContents ?? Data(), encoding: .utf8) ?? ""
    }
    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        FileWrapper(regularFileWithContents: Data(text.utf8))
    }
}
```

For multiple document types, add additional `DocumentGroup` scenes — use `DocumentGroup(viewing:)` for read-only formats.

---

## Platform Conditionals

Always wrap macOS-only scenes in `#if os(macOS)`:

```swift
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }

        #if os(macOS)
        Settings {
            SettingsView()
        }

        MenuBarExtra("Status", systemImage: "bolt") {
            StatusMenu()
        }
        #endif
    }
}
```

---

## Best Practices

- **Use `Settings`** for preferences — prefer this over a custom preferences window
- **Use `MenuBarExtra`** for menu bar items — prefer this over managing AppKit's `NSStatusItem` directly
- **Use `WindowGroup`** as the primary scene — reserve `Window` for supplementary singletons
- **Use `UtilityWindow`** for inspectors/palettes — it handles floating, focus, and visibility automatically
- **Use `DocumentGroup`** for document-based apps — it provides the full File menu and document lifecycle
- **Gate macOS-only scenes** with `#if os(macOS)` for multiplatform projects
- **Use `openWindow(id:)`** to open windows programmatically — it brings existing windows to front
references/macos-views.md
# macOS Views & Components Reference

> macOS-specific SwiftUI views, file operations, drag & drop, and AppKit interop. Covers `HSplitView`, `VSplitView`, `Table`, `PasteButton`, file dialogs, cross-app drag & drop, and `NSViewRepresentable`.

## Table of Contents

- [Quick Lookup Table](#quick-lookup-table)
- [HSplitView & VSplitView (macOS-only)](#hsplitview--vsplitview-macos-only)
- [Table](#table)
- [PasteButton & CopyButton](#pastebutton--copybutton)
- [File Operations](#file-operations)
- [Drag, Drop & Pasteboard](#drag-drop--pasteboard)
- [AppKit Interop](#appkit-interop)
- [Best Practices](#best-practices)

---

## Quick Lookup Table

### Views

| API | Availability | macOS-Only? | Usage |
|-----|-------------|:-----------:|-------|
| `HSplitView` | macOS 10.15+ | Yes | Horizontal resizable split layout with user-draggable dividers |
| `VSplitView` | macOS 10.15+ | Yes | Vertical resizable split layout with user-draggable dividers |
| `Table` | macOS 12.0+ | No | Full multi-column layout with sorting; on iOS compact, columns collapse |
| `PasteButton` | macOS 10.15+ | No | System button that reads clipboard; does NOT auto-validate on macOS |
| `CopyButton` | macOS 15.0+ | Yes | System button that copies `Transferable` content to clipboard |

### File Operations

| API | Availability | macOS-Only? | Usage |
|-----|-------------|:-----------:|-------|
| `fileImporter()` | macOS 11.0+ | No | Native NSOpenPanel with column/list/gallery view, sidebar, tags, QuickLook |
| `fileExporter()` | macOS 11.0+ | No | Native NSSavePanel with format dropdown, tags field |
| `fileMover()` | macOS 11.0+ | No | Native macOS move panel with Finder-like navigation |
| `fileDialogMessage(_:)` | macOS 13.0+ | Yes | Custom message text in file dialogs |
| `fileDialogConfirmationLabel(_:)` | macOS 13.0+ | Yes | Custom confirm button text in file dialogs |
| `fileExporterFilenameLabel(_:)` | macOS 13.0+ | Yes | Custom filename field label in file exporter |

### Drag, Drop & Pasteboard

| API | Availability | macOS-Only? | Usage |
|-----|-------------|:-----------:|-------|
| `onDrag(_:)` / `draggable(_:)` | macOS 11.0+ | No | Drag image follows cursor; items draggable between apps |
| `onDrop(of:delegate:)` / `dropDestination(for:action:)` | macOS 11.0+ | No | Accepts drops from any macOS app including Finder |

### AppKit Interop

| API | Availability | macOS-Only? | Usage |
|-----|-------------|:-----------:|-------|
| `NSViewRepresentable` | macOS 10.15+ | Yes | Wrap an AppKit `NSView` in SwiftUI |
| `NSViewControllerRepresentable` | macOS 10.15+ | Yes | Wrap an AppKit `NSViewController` in SwiftUI |
| `NSHostingController` | macOS 10.15+ | Yes | Host SwiftUI inside an AppKit view controller |
| `NSHostingView` | macOS 10.15+ | Yes | Host SwiftUI inside an AppKit `NSView` hierarchy |

---

## HSplitView & VSplitView (macOS-only)

Resizable split layouts with user-draggable dividers. Use for IDE-style panes where all panels are equal peers. `VSplitView` works identically but splits vertically (use `minHeight` instead).

```swift
HSplitView {
    FileTreeView()
        .frame(minWidth: 200)
    CodeEditorView()
        .frame(minWidth: 400)
    PreviewPane()
        .frame(minWidth: 200)
}
```

> **When to use which:**
> - **`NavigationSplitView`** — sidebar-based navigation (sidebar drives content/detail)
> - **`HSplitView`/`VSplitView`** — IDE-style layouts where all panes are equal peers

---

## Table

For `Table` basics (creation, selection, sorting, adaptive compact layout), see `list-patterns.md`. This section covers macOS-specific table styling.

### Table styles

```swift
// Bordered with visible grid lines (macOS-only)
Table(people) { /* columns */ }
    .tableStyle(.bordered)

// Bordered with alternating row backgrounds
Table(people) { /* columns */ }
    .tableStyle(.bordered(alternatesRowBackgrounds: true))

// Inset (no borders)
Table(people) { /* columns */ }
    .tableStyle(.inset)

// Hide column headers
Table(people) { /* columns */ }
    .tableColumnHeaders(.hidden)
```

---

## PasteButton & CopyButton

### PasteButton

System button that reads clipboard content via `Transferable`. On macOS, it does NOT auto-validate pasteboard changes (unlike iOS).

```swift
struct ClipboardView: View {
    @State private var pastedText = ""

    var body: some View {
        HStack {
            PasteButton(payloadType: String.self) { strings in
                pastedText = strings[0]
            }
            Divider()
            Text(pastedText)
            Spacer()
        }
    }
}
```

### CopyButton (macOS 15.0+, macOS-only)

System button that copies `Transferable` content to the clipboard.

```swift
struct CopyableContent: View {
    let shareableText = "Hello, world!"

    var body: some View {
        HStack {
            Text(shareableText)
            CopyButton(item: shareableText)
        }
    }
}
```

---

## File Operations

### fileImporter

On macOS, presents a native `NSOpenPanel` with column/list/gallery view, sidebar favorites, tags, and QuickLook.

```swift
.fileImporter(
    isPresented: $showImporter,
    allowedContentTypes: [.pdf],
    allowsMultipleSelection: false
) { result in
    if case .success(let urls) = result, let url = urls.first {
        guard url.startAccessingSecurityScopedResource() else { return }
        defer { url.stopAccessingSecurityScopedResource() }
        // use url
    }
}
```

> **Important:** Always call `startAccessingSecurityScopedResource()` on returned URLs, and `stopAccessingSecurityScopedResource()` when done. These are security-scoped bookmarks — access fails without this.

### fileExporter

On macOS, presents a native `NSSavePanel` with format dropdown and tags.

```swift
.fileExporter(
    isPresented: $showExporter,
    document: document,
    contentType: .plainText,
    defaultFilename: "MyFile.txt"
) { result in
    // handle Result<URL, Error>
}
```

### File dialog customization (macOS-only)

Customize text in file dialogs with these macOS-specific modifiers:

```swift
// Custom message and confirm button on file importer
.fileImporter(
    isPresented: $showImporter,
    allowedContentTypes: [.image]
) { result in
    // handle result
}
.fileDialogMessage("Select an image to use as your profile photo")
.fileDialogConfirmationLabel("Use This Photo")

// Custom filename label on file exporter
.fileExporter(
    isPresented: $showExporter,
    document: myDocument,
    contentType: .png
) { result in
    // handle result
}
.fileExporterFilenameLabel("Export As:")
```

---

## Drag, Drop & Pasteboard

On macOS, drag and drop works **across applications** (e.g., drag from your app to Finder, Mail, or other apps).

### Modern approach (Transferable)

```swift
// Drag source
struct DraggableCard: View {
    let item: MyItem

    var body: some View {
        Text(item.title)
            .draggable(item)  // Requires Transferable conformance
    }
}

// Drop target
struct DropZone: View {
    @State private var droppedItems: [MyItem] = []

    var body: some View {
        VStack {
            ForEach(droppedItems) { item in
                Text(item.title)
            }
        }
        .dropDestination(for: MyItem.self) { items, location in
            droppedItems.append(contentsOf: items)
            return true
        }
        .frame(width: 300, height: 200)
        .border(.secondary)
    }
}
```

### Legacy approach (NSItemProvider)

```swift
// Drag source
Image(systemName: "doc")
    .onDrag {
        NSItemProvider(object: fileURL as NSURL)
    }

// Drop target
Text("Drop files here")
    .onDrop(of: [.fileURL], isTargeted: nil) { providers in
        // handle providers
        return true
    }
```

---

## AppKit Interop

### NSViewRepresentable (macOS-only)

Wraps an AppKit `NSView` for use in SwiftUI. Implement `makeNSView(context:)` and `updateNSView(_:context:)`.

```swift
struct WebView: NSViewRepresentable {
    let url: URL
    func makeNSView(context: Context) -> WKWebView { WKWebView() }
    func updateNSView(_ nsView: WKWebView, context: Context) {
        nsView.load(URLRequest(url: url))
    }
}
```

### NSViewRepresentable with Coordinator

Use a Coordinator to forward delegate/target-action callbacks to SwiftUI.

```swift
struct SearchField: NSViewRepresentable {
    @Binding var text: String

    func makeNSView(context: Context) -> NSSearchField {
        let field = NSSearchField()
        field.delegate = context.coordinator
        return field
    }
    func updateNSView(_ nsView: NSSearchField, context: Context) {
        nsView.stringValue = text
    }
    func makeCoordinator() -> Coordinator { Coordinator(text: $text) }

    class Coordinator: NSObject, NSSearchFieldDelegate {
        var text: Binding<String>
        init(text: Binding<String>) { self.text = text }
        func controlTextDidChange(_ obj: Notification) {
            if let field = obj.object as? NSSearchField {
                text.wrappedValue = field.stringValue
            }
        }
    }
}
```

> **Warning:** Never set `frame`/`bounds` directly on the managed `NSView` — SwiftUI owns the layout.

### NSViewControllerRepresentable (macOS-only)

Wraps an AppKit `NSViewController` for use in SwiftUI.

```swift
struct MapViewWrapper: NSViewControllerRepresentable {
    func makeNSViewController(context: Context) -> MapViewController {
        MapViewController()
    }

    func updateNSViewController(_ nsViewController: MapViewController, context: Context) {
        // Update the controller when SwiftUI state changes
    }
}
```

### NSHostingController & NSHostingView (macOS-only)

Host SwiftUI content inside AppKit (reverse direction — AppKit app embedding SwiftUI views).

```swift
// Host SwiftUI as a view controller
let hostingController = NSHostingController(rootView: MySwiftUIView())
window.contentViewController = hostingController

// Host SwiftUI directly as an NSView
let hostingView = NSHostingView(rootView: MySwiftUIView())
someNSView.addSubview(hostingView)
```

---

## Best Practices

- **Use `NavigationSplitView`** for sidebar-driven navigation — reserve `HSplitView`/`VSplitView` for IDE-style equal peer panes
- **Make `Table` adaptive** — handle compact size classes by showing combined info in the first column
- **Always call `startAccessingSecurityScopedResource()`** on URLs from `fileImporter` — they are security-scoped
- **Use `Transferable`** for drag & drop (modern) — fall back to `NSItemProvider` only for legacy compatibility
- **Use `NSViewRepresentable` with Coordinator** when you need delegate callbacks from AppKit views
- **Never set `frame`/`bounds`** directly on views managed by `NSViewRepresentable` — SwiftUI owns the layout
- **Prefer native SwiftUI** over AppKit interop when possible — only use `NSViewRepresentable` for features SwiftUI doesn't provide
references/macos-window-styling.md
# macOS Window & Toolbar Styling Reference

> Window configuration, toolbar styles, sizing, positioning, and navigation patterns specific to macOS SwiftUI apps.

## Table of Contents

- [Quick Lookup Table](#quick-lookup-table)
- [Toolbar Styles](#toolbar-styles)
- [Window Style](#window-style)
- [Window Sizing](#window-sizing)
- [MenuBarExtra Style (macOS-only)](#menubarextra-style-macos-only)
- [Navigation Layout (macOS behavior)](#navigation-layout-macos-behavior)
- [Commands & Keyboard](#commands--keyboard)
- [Best Practices](#best-practices)

---

## Quick Lookup Table

| API | Availability | macOS-Only? | Usage |
|-----|-------------|:-----------:|-------|
| `windowToolbarStyle(_:)` | macOS 11.0+ | Yes | Sets toolbar style: `.unified`, `.unifiedCompact`, `.expanded` |
| `windowStyle(_:)` | macOS 11.0+ | No | Supports `.hiddenTitleBar` for chromeless windows |
| `windowResizability(_:)` | macOS 13.0+ | No | Controls resize handle and green zoom button behavior |
| `defaultSize(width:height:)` | macOS 13.0+ | No | Initial frame size when user creates a new window |
| `defaultPosition(_:)` | macOS 13.0+ | No | Initial window position on screen |
| `windowIdealPlacement(_:)` | macOS 15.0+ | No | Closure with display geometry for precise window positioning |
| `menuBarExtraStyle(_:)` | macOS 13.0+ | Yes | Sets MenuBarExtra to `.menu` or `.window` style |
| `NavigationSplitView` | macOS 13.0+ | No | Columns always visible side-by-side on macOS; translucent sidebar |
| `Inspector` | macOS 14.0+ | No | Trailing-edge sidebar panel; resizable by dragging |

---

## Toolbar Styles

### windowToolbarStyle (macOS-only)

Controls how the toolbar and title bar are displayed. Applied to a scene.

```swift
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        // Title bar and toolbar in a single row
        .windowToolbarStyle(.unified)
    }
}
```

**Available styles:**

| Style | Description |
|-------|-------------|
| `.automatic` | System default |
| `.unified` | Title bar and toolbar in a single combined row |
| `.unifiedCompact` | Same as unified but with reduced vertical height |
| `.expanded` | Title bar displayed above the toolbar (more toolbar space) |

```swift
// Unified compact — minimal chrome
.windowToolbarStyle(.unifiedCompact)

// Expanded — title bar above toolbar
.windowToolbarStyle(.expanded)

// Unified with title hidden
.windowToolbarStyle(.unified(showsTitle: false))
```

### Toolbar content

```swift
struct ContentView: View {
    @State private var searchText = ""

    var body: some View {
        NavigationSplitView {
            SidebarView()
        } detail: {
            DetailView()
        }
        .toolbar {
            ToolbarItem(placement: .automatic) {
                Button(action: addItem) {
                    Label("Add", systemImage: "plus")
                }
            }
        }
        .searchable(text: $searchText, placement: .sidebar)
    }
}
```

---

## Window Style

### windowStyle

Set the visual style of a window. Use `.hiddenTitleBar` for chromeless, immersive windows.

```swift
// Standard title bar (default)
WindowGroup {
    ContentView()
}
.windowStyle(.titleBar)

// Hidden title bar — chromeless window
WindowGroup {
    ContentView()
}
.windowStyle(.hiddenTitleBar)
```

> **Use case:** `.hiddenTitleBar` is useful for media players, custom-chrome apps, or immersive experiences where the standard title bar is unwanted.

---

## Window Sizing

### windowResizability, defaultSize, defaultPosition

These modifiers work together to configure window sizing and placement:

```swift
WindowGroup {
    ContentView()
        .frame(minWidth: 600, minHeight: 400)
}
.defaultSize(width: 900, height: 600)
.defaultPosition(.center)
.windowResizability(.contentMinSize)
```

**`windowResizability` options:**

| Value | Behavior |
|-------|----------|
| `.automatic` | System decides resize behavior |
| `.contentSize` | Fixed to content size; no user resize; zoom button disabled |
| `.contentMinSize` | Resizable with minimum based on content's `minWidth`/`minHeight` |

**`defaultPosition` options:** `.center`, `.topLeading`, `.top`, `.topTrailing`, `.leading`, `.trailing`, `.bottomLeading`, `.bottom`, `.bottomTrailing`

**Guidelines:**
- Set `minWidth`/`minHeight` via `.frame()` on content, enforce with `.contentMinSize`
- Use `.defaultSize()` for initial dimensions (larger than minimums)
- `defaultSize` also accepts `CGSize`

### windowIdealPlacement (macOS 15.0+)

For precise programmatic positioning, use a closure with display geometry:

```swift
.windowIdealPlacement { context in
    let screen = context.defaultDisplay.visibleArea
    return WindowPlacement(x: screen.midX, y: screen.midY,
                           width: screen.width / 2, height: screen.height)
}
```

---

## MenuBarExtra Style (macOS-only)

Choose between dropdown menu and popover panel for `MenuBarExtra`.

```swift
// Dropdown menu (default)
MenuBarExtra("Status", systemImage: "chart.bar") {
    Button("Action") { /* ... */ }
}
.menuBarExtraStyle(.menu)

// Popover panel with custom SwiftUI content
MenuBarExtra("Status", systemImage: "chart.bar") {
    DashboardView()
}
.menuBarExtraStyle(.window)
```

---

## Navigation Layout (macOS behavior)

### NavigationSplitView

On macOS, `NavigationSplitView` displays columns side-by-side (never overlaid). The sidebar gets a translucent material background. Columns support variable-width resizing by the user.

```swift
NavigationSplitView {
    List(items, selection: $selectedId) { item in
        Text(item.name)
    }
    .navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 300)
} detail: {
    DetailView(id: selectedId)
}
.navigationSplitViewStyle(.balanced)
```

Use the three-column variant (`sidebar` / `content` / `detail`) for master-detail-detail layouts. Customize column widths with `.navigationSplitViewColumnWidth(min:ideal:max:)`.

### Inspector (macOS 14.0+)

A trailing-edge panel for supplementary information. On macOS, it appears as a sidebar-style panel that can be resized by dragging its edge.

```swift
struct ContentView: View {
    @State private var showInspector = false

    var body: some View {
        MainContent()
            .inspector(isPresented: $showInspector) {
                InspectorView()
                    .inspectorColumnWidth(min: 200, ideal: 250, max: 400)
            }
            .toolbar {
                ToolbarItem {
                    Button {
                        showInspector.toggle()
                    } label: {
                        Label("Inspector", systemImage: "info.circle")
                    }
                }
            }
    }
}
```

---

## Commands & Keyboard

### Commands, CommandGroup, CommandMenu

Define menu bar commands. On macOS, these populate the menu bar directly. On iOS, they create key commands.

```swift
.commands {
    CommandMenu("Tools") {
        Button("Run Analysis") { /* ... */ }
            .keyboardShortcut("r", modifiers: [.command, .shift])
    }
    CommandGroup(after: .newItem) {
        Button("New From Template...") { /* ... */ }
    }
}
```

**`CommandGroup` placement options:** `.replacing(_:)` replaces a system group, `.before(_:)` / `.after(_:)` inserts adjacent to it. Common placements: `.newItem`, `.saveItem`, `.help`, `.toolbar`, `.sidebar`.

### KeyboardShortcut

On macOS, shortcuts are displayed alongside menu items and in button tooltips on hover.

```swift
Button("Save") {
    save()
}
.keyboardShortcut("s", modifiers: .command)

Button("Delete") {
    delete()
}
.keyboardShortcut(.delete, modifiers: .command)
```

### openWindow

Programmatically open a window. If the target window is already open, brings it to the front.

```swift
struct ToolbarActions: View {
    @Environment(\.openWindow) private var openWindow

    var body: some View {
        Button("Connection Doctor") {
            openWindow(id: "connection-doctor")
        }

        Button("Show Message") {
            openWindow(value: message.id)  // Type-matched to WindowGroup
        }
    }
}
```

---

## Best Practices

- **Use `.unified` or `.unifiedCompact`** for most apps — `.expanded` only when you need many toolbar items
- **Set min frame sizes on content** and use `.windowResizability(.contentMinSize)` to enforce them
- **Always provide `defaultSize`** so new windows start at a reasonable size
- **Use `NavigationSplitView`** for sidebar navigation — not `HSplitView`
- **Use `Inspector`** for supplementary panels — it integrates with the toolbar automatically
- **Define `Commands`** for all repeatable actions — users expect keyboard shortcuts on macOS
- **Use `#if os(macOS)`** to wrap macOS-only window configuration in multiplatform projects
references/performance-patterns.md
# SwiftUI Performance Patterns Reference

## Table of Contents

- [Performance Optimization](#performance-optimization)
- [Anti-Patterns](#anti-patterns)
- [Summary Checklist](#summary-checklist)

## Performance Optimization

### 1. Avoid Redundant State Updates

SwiftUI doesn't compare values before triggering updates:

```swift
// BAD - triggers update even if value unchanged
.onReceive(publisher) { value in
    self.currentValue = value  // Always triggers body re-evaluation
}

// GOOD - only update when different
.onReceive(publisher) { value in
    if self.currentValue != value {
        self.currentValue = value
    }
}
```

### 2. Optimize Hot Paths

Hot paths are frequently executed code (scroll handlers, animations, gestures):

```swift
// BAD - updates state on every scroll position change
.onPreferenceChange(ScrollOffsetKey.self) { offset in
    shouldShowTitle = offset.y <= -32  // Fires constantly during scroll!
}

// GOOD - only update when threshold crossed
.onPreferenceChange(ScrollOffsetKey.self) { offset in
    let shouldShow = offset.y <= -32
    if shouldShow != shouldShowTitle {
        shouldShowTitle = shouldShow  // Fires only when crossing threshold
    }
}
```

### 3. Pass Only What Views Need

**Avoid passing large "config" or "context" objects.** Pass only the specific values each view needs.

```swift
// Good - pass specific values
ThemeSelector(theme: config.theme)
FontSizeSlider(fontSize: config.fontSize)

// Avoid - passing entire config (creates broad dependency)
ThemeSelector(config: config)  // Notified of ALL config changes
```

With `ObservableObject`, any `@Published` change triggers all observers. With `@Observable`, views update only when accessed properties change, but passing entire objects still creates broader dependencies than necessary.

### 4. Use Equatable Views

For views with expensive bodies, conform to `Equatable`:

```swift
struct ExpensiveView: View, Equatable {
    let data: SomeData

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.data.id == rhs.data.id  // Custom equality check
    }

    var body: some View {
        // Expensive computation
    }
}

// Usage
ExpensiveView(data: data)
    .equatable()  // Use custom equality
```

**Caution**: If you add new state or dependencies to your view, remember to update your `==` function!

### 5. POD Views for Fast Diffing

**POD (Plain Old Data) views use `memcmp` for fastest diffing.** A view is POD if it only contains simple value types and no property wrappers.

```swift
// POD view - fastest diffing
struct FastView: View {
    let title: String
    let count: Int
    
    var body: some View {
        Text("\(title): \(count)")
    }
}

// Non-POD view - uses reflection or custom equality
struct SlowerView: View {
    let title: String
    @State private var isExpanded = false  // Property wrapper makes it non-POD
    
    var body: some View {
        Text(title)
    }
}
```

**Advanced Pattern**: Wrap expensive non-POD views in POD parent views:

```swift
// POD wrapper for fast diffing
struct ExpensiveView: View {
    let value: Int
    
    var body: some View {
        ExpensiveViewInternal(value: value)
    }
}

// Internal view with state
private struct ExpensiveViewInternal: View {
    let value: Int
    @State private var item: Item?
    
    var body: some View {
        // Expensive rendering
    }
}
```

**Why**: The POD parent uses fast `memcmp` comparison. Only when `value` changes does the internal view get diffed.

### 6. Lazy Loading

Use lazy containers for large collections:

```swift
// BAD - creates all views immediately
ScrollView {
    VStack {
        ForEach(items) { item in
            ExpensiveRow(item: item)
        }
    }
}

// GOOD - creates views on demand
ScrollView {
    LazyVStack {
        ForEach(items) { item in
            ExpensiveRow(item: item)
        }
    }
}
```

**iOS 26+ note**: Nested scroll views containing lazy stacks now automatically defer loading their children until they are about to appear, matching the behavior of top-level lazy stacks. This benefits patterns like horizontal photo carousels inside a vertical scroll view.

> Source: "What's new in SwiftUI" (WWDC25, session 256)

### 7. Task Cancellation

Cancel async work when view disappears:

```swift
struct DataView: View {
    @State private var data: [Item] = []

    var body: some View {
        List(data) { item in
            Text(item.name)
        }
        .task {
            // Automatically cancelled when view disappears
            data = await fetchData()
        }
    }
}
```

### 8. Debug View Updates

**Use `Self._printChanges()` or `Self._logChanges()` to debug unexpected view updates.**

```swift
struct DebugView: View {
    @State private var count = 0
    @State private var name = ""
    
    var body: some View {
        #if DEBUG
        let _ = Self._logChanges()  // Xcode 15.1+: logs to com.apple.SwiftUI subsystem
        #endif
        
        VStack {
            Text("Count: \(count)")
            Text("Name: \(name)")
        }
    }
}
```

- `Self._printChanges()`: Prints which properties changed to standard output.
- `Self._logChanges()` (iOS 17+): Logs to the `com.apple.SwiftUI` subsystem with category "Changed Body Properties", using `os_log` for structured output.

Both print `@self` when the view value itself changed and `@identity` when the view's persistent data was recycled.

**Why**: This helps identify which state changes are causing view updates. Isolating redraw triggers into single-responsibility subviews is often the fix -- extracting a subview means SwiftUI can skip its body when its inputs haven't changed.

### 9. Eliminate Unnecessary Dependencies

**Narrow state scope to reduce update fan-out.** Instead of passing an entire `@Observable` model to a row view (which creates a dependency on all accessed properties), pass only the specific values the view needs as `let` properties.

```swift
// Bad - broad dependency on entire model
struct ItemRow: View {
    @Environment(AppModel.self) private var model
    let item: Item
    var body: some View { Text(item.name).foregroundStyle(model.theme.primaryColor) }
}

// Good - narrow dependency
struct ItemRow: View {
    let item: Item
    let themeColor: Color
    var body: some View { Text(item.name).foregroundStyle(themeColor) }
}
```

**Avoid storing frequently-changing values in the environment.** Every write to *any* environment key forces every view that reads *any* key in that subtree to be checked. So a high-frequency value (scroll offset, window/container size, drag translation, per-frame animation progress, timer ticks, hover location) flowing into an `@Entry` or `.environment(\.key, value)` becomes an invalidation storm.

Instead, hold the value in an `@Observable` model and expose a **coarsened** value — a boolean threshold rather than the raw measurement — so views invalidate only when crossing a meaningful boundary:

```swift
@MainActor @Observable
final class ViewportModel {
    var width: CGFloat = 0 { didSet { isWide = width > 600 } }
    private(set) var isWide = false   // readers invalidate only when this flips
}
```

The same shape applies per row in a list: storing the raw offset on a model and having every row read it still invalidates all visible rows every frame. Give each item its own `@Observable` whose properties track only that item's derived state (e.g. `isVisible`), so each row invalidates at most twice (enter/leave) regardless of scroll speed — see item #10 below.

For purely visual effects driven by scroll position (opacity, scale, rotation), prefer `scrollTransition` or `visualEffect(in:)` — they push the per-frame work to the renderer and skip body re-evaluation entirely. Reach for the `@Observable` + coarsening pattern only when the scroll-derived value must drive non-rendering logic (model updates, prefetches, sibling state).

> Source: "Optimize SwiftUI performance with Instruments" (WWDC25, session 306)

### 10. @Observable Dependency Granularity

**Consider per-item `@Observable` state holders (one per row/item) to narrow update scope.** When multiple list items share a dependency on the same `@Observable` array, changing one element causes all items to re-evaluate their bodies.

```swift
// BAD - all item views depend on the full favorites array
@Observable
class ModelData {
    var favorites: [Landmark] = []

    func isFavorite(_ landmark: Landmark) -> Bool {
        favorites.contains(landmark)
    }
}

struct LandmarkRow: View {
    let landmark: Landmark
    @Environment(ModelData.self) private var model

    var body: some View {
        HStack {
            Text(landmark.name)
            if model.isFavorite(landmark) {
                Image(systemName: "heart.fill")
            }
        }
    }
}

// GOOD - each item has its own observable view model
@Observable
class LandmarkViewModel {
    var isFavorite: Bool = false
}

struct LandmarkRow: View {
    let landmark: Landmark
    let viewModel: LandmarkViewModel

    var body: some View {
        HStack {
            Text(landmark.name)
            if viewModel.isFavorite {
                Image(systemName: "heart.fill")
            }
        }
    }
}
```

**Why**: With the bad pattern, toggling one favorite marks the entire array as changed, causing every `LandmarkRow` to re-run its body. With per-item view models, only the toggled item's body runs.

> Source: "Optimize SwiftUI performance with Instruments" (WWDC25, session 306)

### 11. Off-Main-Thread Closures

**SwiftUI may call certain closures on a background thread for performance.** These closures must be `Sendable` and should avoid accessing `@MainActor`-isolated state directly. Instead, capture needed values in the closure's capture list.

Closures that may run off the main thread:
- `Shape.path(in:)`
- `visualEffect` closure
- `Layout` protocol methods
- `onGeometryChange` transform closure

```swift
// BAD - accessing @MainActor state directly
.visualEffect { content, geometry in
    content.blur(radius: self.pulse ? 5 : 0)  // Compiler error: @MainActor isolated
}

// GOOD - capture the value
.visualEffect { [pulse] content, geometry in
    content.blur(radius: pulse ? 5 : 0)
}
```

> Source: "Explore concurrency in SwiftUI" (WWDC25, session 266)

### 12. Common Performance Issues

**Be aware of common performance bottlenecks in SwiftUI:**

- View invalidation storms from broad state changes
- Unstable identity in lists causing excessive diffing
- Heavy work in `body` (formatting, sorting, image decoding)
- Layout thrash from deep stacks or preference chains

**When performance issues arise**, suggest the user profile with Instruments (SwiftUI template) to identify specific bottlenecks.

## Anti-Patterns

### 1. Creating Objects in Body

```swift
// BAD - creates new formatter every body call
var body: some View {
    let formatter = DateFormatter()
    formatter.dateStyle = .long
    return Text(formatter.string(from: date))
}

// GOOD - static or stored formatter
private static let dateFormatter: DateFormatter = {
    let f = DateFormatter()
    f.dateStyle = .long
    return f
}()

var body: some View {
    Text(Self.dateFormatter.string(from: date))
}
```

### 2. Heavy Computation in Body

**Keep view body simple and pure.** Avoid side effects, dispatching, or complex logic.

```swift
// BAD - sorts array every body call
var body: some View {
    List(items.sorted { $0.name < $1.name }) { item in Text(item.name) }
}

// GOOD - compute once, update via onChange or a computed property in the model
@State private var sortedItems: [Item] = []

var body: some View {
    List(sortedItems) { item in Text(item.name) }
        .onChange(of: items) { _, newItems in
            sortedItems = newItems.sorted { $0.name < $1.name }
        }
}
```

Move sorting, filtering, and formatting into models or computed properties. The `body` should be a pure structural representation of state.

### 3. Unnecessary State

```swift
// BAD - derived state stored separately
@State private var items: [Item] = []
@State private var itemCount: Int = 0  // Unnecessary!

// GOOD - compute derived values
@State private var items: [Item] = []

var itemCount: Int { items.count }  // Computed property
```

## Summary Checklist

- [ ] State updates check for value changes before assigning
- [ ] Hot paths minimize state updates
- [ ] Pass only needed values to views (avoid large config objects)
- [ ] Large lists use `LazyVStack`/`LazyHStack`
- [ ] No object creation in `body`
- [ ] Heavy computation moved out of `body`
- [ ] Body kept simple and pure (no side effects)
- [ ] Derived state computed, not stored
- [ ] Use `Self._logChanges()` or `Self._printChanges()` to debug unexpected updates
- [ ] Equatable conformance for expensive views (when appropriate)
- [ ] Consider POD view wrappers for advanced optimization
- [ ] Consider using granular @Observable dependencies for list items (smaller observable units per row when it measurably reduces updates)
- [ ] Frequently-changing values not stored in the environment
- [ ] Sendable closures (Shape, visualEffect, Layout) capture values instead of accessing @MainActor state
references/previews.md
# SwiftUI Previews Reference

## Table of Contents

- [Preview Macro](#preview-macro)
- [Preview with Mock Data](#preview-with-mock-data)
- [@Previewable Property Wrappers](#previewable-property-wrappers)
- [Common Diagnostics](#common-diagnostics)
- [Summary Checklist](#summary-checklist)

---

## Preview Macro

The `#Preview` macro (Swift 5.9+, Xcode 15+) is the modern way to declare previews. The legacy `PreviewProvider` protocol still works; prefer `#Preview` for new code because it's less verbose and supports inline traits.

### Basic Usage

```swift
// Modern: #Preview macro
#Preview {
    ContentView()
}

// Named preview
#Preview("Dark Mode") {
    ContentView()
        .preferredColorScheme(.dark)
}

// Legacy: PreviewProvider — still valid, but verbose for new code
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
```

### Multiple Previews

Declare one `#Preview` per meaningful state so each renders independently in the canvas:

```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

Traits configure the preview environment without modifying the view itself:

```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 Inside NavigationStack

Wrap previewed destinations in their navigation container so toolbar items, titles, and back buttons render correctly:

```swift
#Preview {
    NavigationStack {
        DetailView(item: .sample)
    }
}
```

---

## Preview with Mock Data

Previews must compile and render without external dependencies. Live services, network calls, and disk I/O make previews slow, flaky, or broken; use self-contained sample data instead.

### Static Sample Data

Expose sample values as static properties on the model itself so any preview can reuse them without reconstructing values inline:

```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

For views driven by an `@Observable` model (see `state-management.md` for fundamentals), expose pre-configured instances on the model itself:

```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

Inject any environment values the view depends on so the preview reflects a realistic runtime context:

```swift
#Preview {
    OrderDetailView(order: .sample)
        .environment(CartModel.preview)
        .environment(\.locale, Locale(identifier: "ja_JP"))
        .environment(\.dynamicTypeSize, .xxxLarge)
}
```

### Mocking Async Data Sources

When a view depends on a network or data service, give the dependency a protocol abstraction so previews can inject a synchronous mock that returns sample data immediately. This is one approach — adapt it to whatever pattern the surrounding codebase already uses.

```swift
protocol DataFetching {
    func fetchItems() async throws -> [Item]
}

struct LiveDataFetcher: DataFetching {
    let url: URL

    func fetchItems() async throws -> [Item] {
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([Item].self, from: data)
    }
}

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+) lets you use `@State`, `@FocusState`, and other property wrappers directly inside a `#Preview` block, removing the need for a wrapper view to host interactive state.

### Interactive State

```swift
// @Previewable: interactive toggle inline in the 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

When seeding initial focus inside a preview, prefer `.defaultFocus` over writing to `@FocusState` from `.onAppear`. `.onAppear` can race the initial render and the focus assignment may be lost. See `focus-patterns.md` for the underlying rationale.

```swift
#Preview {
    @Previewable @FocusState var isFocused: Bool

    TextField("Search", text: .constant(""))
        .focused($isFocused)
        .defaultFocus($isFocused, true)
}
```

### Fallback for Pre-iOS 18 Targets

If the project's minimum deployment target is below iOS 18, `@Previewable` is unavailable. Fall back to a wrapper view:

```swift
private struct SliderPreview: View {
    @State private var value = 0.5
    var body: some View {
        CustomSlider(value: $value)
    }
}

#Preview {
    SliderPreview()
}
```

---

## Common Diagnostics

| Symptom | Cause | Fix |
|---|---|---|
| `#Preview` body type mismatch | The closure returns a non-`View` type | Make sure the final expression is a `View` |
| `@Previewable` only available in iOS 18+ | Using `@Previewable` with a lower deployment target | Use a wrapper view, or gate with `#available` |
| Preview crashes with "missing environment" | An `@Environment(SomeType.self)` value is not injected | Add `.environment(SomeType.preview)` to the preview |
| Preview hangs or renders blank | View depends on async data that never resolves | Inject a mock that returns immediately with sample data |
| `@MainActor`-isolated model accessed from non-isolated context | A preview helper touches main-actor-only API off the main actor | Mark the helper or the preview body `@MainActor` |

---

## Summary Checklist

- [ ] Prefer `#Preview` for new previews; `PreviewProvider` is still valid for older code
- [ ] Provide a named preview for each meaningful state (default, empty, error, loading)
- [ ] Use `@Previewable` for interactive previews when targeting iOS 18+; wrapper views otherwise
- [ ] Expose static `.sample` / `.preview` data on models so previews don't reconstruct values inline
- [ ] Inject mock services through a protocol when a view depends on async data
- [ ] Never depend on live network or disk I/O in a preview
- [ ] Prefer `.defaultFocus` over `.onAppear` writes when seeding `@FocusState` in previews
references/scroll-patterns.md
# SwiftUI ScrollView Patterns Reference

## Table of Contents

- [ScrollViewReader for Programmatic Scrolling](#scrollviewreader-for-programmatic-scrolling)
- [Scroll Position Tracking](#scroll-position-tracking)
- [Scroll Transitions and Effects](#scroll-transitions-and-effects)
- [Scroll Target Behavior](#scroll-target-behavior)
- [Summary Checklist](#summary-checklist)

## ScrollViewReader for Programmatic Scrolling

**Use `ScrollViewReader` for scroll-to-top, scroll-to-bottom, and anchor-based jumps.**

```swift
struct ChatView: View {
    @State private var messages: [Message] = []
    private let bottomID = "bottom"
    
    var body: some View {
        ScrollViewReader { proxy in
            ScrollView {
                LazyVStack {
                    ForEach(messages) { message in
                        MessageRow(message: message)
                            .id(message.id)
                    }
                    Color.clear
                        .frame(height: 1)
                        .id(bottomID)
                }
            }
            .onChange(of: messages.count) { _, _ in
                withAnimation {
                    proxy.scrollTo(bottomID, anchor: .bottom)
                }
            }
            .onAppear {
                proxy.scrollTo(bottomID, anchor: .bottom)
            }
        }
    }
}
```

### Scroll-to-Top Pattern

```swift
struct FeedView: View {
    @State private var items: [Item] = []
    @State private var scrollToTop = false
    private let topID = "top"
    
    var body: some View {
        ScrollViewReader { proxy in
            ScrollView {
                LazyVStack {
                    Color.clear
                        .frame(height: 1)
                        .id(topID)
                    
                    ForEach(items) { item in
                        ItemRow(item: item)
                    }
                }
            }
            .onChange(of: scrollToTop) { _, shouldScroll in
                if shouldScroll {
                    withAnimation {
                        proxy.scrollTo(topID, anchor: .top)
                    }
                    scrollToTop = false
                }
            }
        }
    }
}
```

**Why**: `ScrollViewReader` provides programmatic scroll control with stable anchors. Always use stable IDs and explicit animations.

## Scroll Position Tracking

### Basic Scroll Position

**Avoid** - Storing scroll position directly triggers view updates on every scroll frame:

```swift
// ❌ Bad Practice - causes unnecessary re-renders
struct ContentView: View {
    @State private var scrollPosition: CGFloat = 0

    var body: some View {
        ScrollView {
            content
                .background(
                    GeometryReader { geometry in
                        Color.clear
                            .preference(
                                key: ScrollOffsetPreferenceKey.self,
                                value: geometry.frame(in: .named("scroll")).minY
                            )
                    }
                )
        }
        .coordinateSpace(name: "scroll")
        .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
            scrollPosition = value
        }
    }
}
```

**Preferred** - Check scroll position and update a flag based on thresholds for smoother, more efficient scrolling:

```swift
// ✅ Good Practice - only updates state when crossing threshold
struct ContentView: View {
    @State private var startAnimation: Bool = false

    var body: some View {
        ScrollView {
            content
                .background(
                    GeometryReader { geometry in
                        Color.clear
                            .preference(
                                key: ScrollOffsetPreferenceKey.self,
                                value: geometry.frame(in: .named("scroll")).minY
                            )
                    }
                )
        }
        .coordinateSpace(name: "scroll")
        .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
            if value < -100 {
                startAnimation = true
            } else {
                startAnimation = false
            }
        }
    }
}

struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}
```

### Scroll-Based Header Visibility

```swift
struct ContentView: View {
    @State private var showHeader = true
    
    var body: some View {
        VStack(spacing: 0) {
            if showHeader {
                HeaderView()
                    .transition(.move(edge: .top))
            }
            
            ScrollView {
                content
                    .background(
                        GeometryReader { geometry in
                            Color.clear
                                .preference(
                                    key: ScrollOffsetPreferenceKey.self,
                                    value: geometry.frame(in: .named("scroll")).minY
                                )
                        }
                    )
            }
            .coordinateSpace(name: "scroll")
            .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
                if offset < -50 { // Scrolling down
                   withAnimation { showHeader = false }
                } else if offset > 50 { // Scrolling up
                  withAnimation { showHeader = true }
                }
            }
        }
    }
}
```

## Scroll Transitions and Effects

> **iOS 17+**: All APIs in this section require iOS 17 or later.

### Scroll-Based Opacity

```swift
struct ParallaxView: View {
    var body: some View {
        ScrollView {
            LazyVStack(spacing: 20) {
                ForEach(items) { item in
                    ItemCard(item: item)
                        .visualEffect { content, geometry in
                            let frame = geometry.frame(in: .scrollView)
                            let distance = min(0, frame.minY)
                            return content
                                .opacity(1 + distance / 200)
                        }
                }
            }
        }
    }
}
```

### Parallax Effect

```swift
struct ParallaxHeader: View {
    var body: some View {
        ScrollView {
            VStack(spacing: 0) {
                Image("hero")
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(height: 300)
                    .visualEffect { content, geometry in
                        let offset = geometry.frame(in: .scrollView).minY
                        return content
                            .offset(y: offset > 0 ? -offset * 0.5 : 0)
                    }
                    .clipped()
                
                ContentView()
            }
        }
    }
}
```

## Scroll Target Behavior

> **iOS 17+**: All APIs in this section require iOS 17 or later.

### Paging ScrollView

```swift
struct PagingView: View {
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 0) {
                ForEach(pages) { page in
                    PageView(page: page)
                        .containerRelativeFrame(.horizontal)
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.paging)
    }
}
```

### Snap to Items

```swift
struct SnapScrollView: View {
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 16) {
                ForEach(items) { item in
                    ItemCard(item: item)
                        .frame(width: 280)
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.viewAligned)
        .contentMargins(.horizontal, 20)
    }
}
```

## Summary Checklist

- [ ] Use `ScrollViewReader` with stable IDs for programmatic scrolling
- [ ] Always use explicit animations with `scrollTo()`
- [ ] Use `.visualEffect` for scroll-based visual changes
- [ ] Use `.scrollTargetBehavior(.paging)` for paging behavior
- [ ] Use `.scrollTargetBehavior(.viewAligned)` for snap-to-item behavior
- [ ] Gate frequent scroll position updates by thresholds
- [ ] Use preference keys for custom scroll position tracking
references/sheet-navigation-patterns.md
# SwiftUI Sheet, Navigation & Inspector Patterns Reference

## Table of Contents

- [Sheet Patterns](#sheet-patterns)
- [Navigation Patterns](#navigation-patterns)
- [Multi-Column Navigation with NavigationSplitView](#multi-column-navigation-with-navigationsplitview)
- [Inspector](#inspector)
- [Presentation Modifiers](#presentation-modifiers)
- [Summary Checklist](#summary-checklist)

## Sheet Patterns

### Item-Driven Sheets (Preferred)

**Use `.sheet(item:)` instead of `.sheet(isPresented:)` when presenting model-based content.**

```swift
// Good - item-driven
@State private var selectedItem: Item?

var body: some View {
    List(items) { item in
        Button(item.name) {
            selectedItem = item
        }
    }
    .sheet(item: $selectedItem) { item in
        ItemDetailSheet(item: item)
    }
}

// Avoid - boolean flag requires separate state
@State private var showSheet = false
@State private var selectedItem: Item?

var body: some View {
    List(items) { item in
        Button(item.name) {
            selectedItem = item
            showSheet = true
        }
    }
    .sheet(isPresented: $showSheet) {
        if let selectedItem {
            ItemDetailSheet(item: selectedItem)
        }
    }
}
```

**Why**: `.sheet(item:)` automatically handles presentation state and avoids optional unwrapping in the sheet body.

### Sheets Own Their Actions

**Sheets should handle their own dismiss and actions internally** using `@Environment(\.dismiss)`. Avoid passing `onSave`/`onCancel` closures from the parent -- it creates callback prop-drilling and reduces reusability.

```swift
struct EditItemSheet: View {
    @Environment(\.dismiss) private var dismiss
    let item: Item
    @State private var name: String

    init(item: Item) {
        self.item = item
        _name = State(initialValue: item.name)
    }

    var body: some View {
        NavigationStack {
            Form { TextField("Name", text: $name) }
                .navigationTitle("Edit Item")
                .toolbar {
                    ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
                    ToolbarItem(placement: .confirmationAction) { Button("Save") { /* save and dismiss */ } }
                }
        }
    }
}
```

### Enum-Based Sheet Management

When presenting multiple different sheets, use an `Identifiable` enum with `.sheet(item:)` instead of multiple boolean state properties:

```swift
struct ArticlesView: View {
    enum Sheet: Identifiable {
        case add, edit(Article), categories
        var id: String {
            switch self {
            case .add: "add"
            case .edit(let a): "edit-\(a.id)"
            case .categories: "categories"
            }
        }
    }

    @State private var presentedSheet: Sheet?

    var body: some View {
        List { /* ... */ }
            .toolbar {
                Button("Add") { presentedSheet = .add }
            }
            .sheet(item: $presentedSheet) { sheet in
                switch sheet {
                case .add: AddArticleView()
                case .edit(let article): EditArticleView(article: article)
                case .categories: CategoriesView()
                }
            }
    }
}
```

**Why**: A single `@State` property and one `.sheet(item:)` modifier replaces N boolean properties and N sheet modifiers, improving readability and preventing only-one-sheet-at-a-time conflicts.

## Navigation Patterns

### Type-Safe Navigation with NavigationStack

```swift
struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("Profile", value: Route.profile)
                NavigationLink("Settings", value: Route.settings)
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .profile:
                    ProfileView()
                case .settings:
                    SettingsView()
                }
            }
        }
    }
}

enum Route: Hashable {
    case profile
    case settings
}
```

### Programmatic Navigation

```swift
struct ContentView: View {
    @State private var navigationPath = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $navigationPath) {
            List {
                Button("Go to Detail") {
                    navigationPath.append(DetailRoute.item(id: 1))
                }
            }
            .navigationDestination(for: DetailRoute.self) { route in
                switch route {
                case .item(let id):
                    ItemDetailView(id: id)
                }
            }
        }
    }
}

enum DetailRoute: Hashable {
    case item(id: Int)
}
```

## Multi-Column Navigation with NavigationSplitView

### Two-Column Layout

Use `NavigationSplitView` for sidebar-driven navigation. Available on iOS 16+, macOS 13+, tvOS 16+, watchOS 9+.

```swift
struct ContentView: View {
    @State private var selectedItem: Item.ID?

    var body: some View {
        NavigationSplitView {
            List(items, selection: $selectedItem) { item in
                Text(item.name)
            }
            .navigationTitle("Items")
        } detail: {
            if let selectedItem, let item = items.first(where: { $0.id == selectedItem }) {
                ItemDetailView(item: item)
            } else {
                ContentUnavailableView("Select an Item", systemImage: "doc")
            }
        }
    }
}
```

### Three-Column Layout

```swift
struct ContentView: View {
    @State private var departmentId: Department.ID?
    @State private var employeeIds = Set<Employee.ID>()

    var body: some View {
        NavigationSplitView {
            List(model.departments, selection: $departmentId) { dept in
                Text(dept.name)
            }
        } content: {
            if let department = model.department(id: departmentId) {
                List(department.employees, selection: $employeeIds) { emp in
                    Text(emp.name)
                }
            } else {
                Text("Select a department")
            }
        } detail: {
            EmployeeDetails(for: employeeIds)
        }
    }
}
```

### Configuration

- **Column visibility**: `NavigationSplitView(columnVisibility: $visibility)` with `NavigationSplitViewVisibility` (`.detailOnly`, `.doubleColumn`, `.all`)
- **Column widths**: `.navigationSplitViewColumnWidth(min:ideal:max:)` on each column
- **Compact column**: `NavigationSplitView(preferredCompactColumn: $column)` to control which column shows on narrow devices
- **Style**: `.navigationSplitViewStyle(.balanced)` or `.prominentDetail` (default)

### Platform Behavior

| Platform | Behavior |
|----------|----------|
| **macOS** | Columns always visible side-by-side; sidebar has translucent material; variable-width column resizing by dragging |
| **iPadOS (regular)** | Sidebar can overlay or push detail; supports column visibility toggle via toolbar button |
| **iOS / iPadOS (compact)** | Collapses into a single `NavigationStack`; sidebar items show disclosure chevrons; back button navigates between columns |
| **iPhone (all sizes)** | Always collapsed into a stack; sidebar appears as the root list; selections push detail onto the stack |
| **watchOS / tvOS** | Collapses into a single stack |

## Inspector

> **Availability:** iOS 17.0+, macOS 14.0+

A trailing-edge panel for supplementary information.

On wider size classes (macOS, iPad landscape), it appears as a **trailing column**. On compact size classes (iPhone), it **adapts to a sheet** automatically.

### Basic Inspector

```swift
struct ShapeEditor: View {
    @State private var showInspector = false

    var body: some View {
        MyEditorView()
            .inspector(isPresented: $showInspector) {
                InspectorContent()
            }
            .toolbar {
                ToolbarItem {
                    Button {
                        showInspector.toggle()
                    } label: {
                        Label("Inspector", systemImage: "info.circle")
                    }
                }
            }
    }
}
```

### Inspector with Column Width

```swift
MyEditorView()
    .inspector(isPresented: $showInspector) {
        InspectorContent()
            .inspectorColumnWidth(min: 200, ideal: 250, max: 400)
    }
```

### Inspector with Fixed Width

```swift
MyEditorView()
    .inspector(isPresented: $showInspector) {
        InspectorContent()
            .inspectorColumnWidth(300)
    }
```

### Platform Behavior

| Platform | Behavior |
|----------|----------|
| **macOS** | Trailing-edge sidebar panel; resizable by dragging edge; integrates with window toolbar |
| **iPadOS (regular)** | Trailing column alongside content; toggleable via toolbar button |
| **iOS / iPadOS (compact)** | Adapts to a sheet presentation; swipe-to-dismiss supported |
| **iPhone (all sizes)** | Always presented as a sheet (no trailing column); dismiss via swipe or button |

> **Tip:** Use `InspectorCommands` in your app's `.commands` to include the default inspector toggle keyboard shortcut.

## Presentation Modifiers

### Full Screen Cover

```swift
struct ContentView: View {
    @State private var showFullScreen = false
    
    var body: some View {
        Button("Show Full Screen") {
            showFullScreen = true
        }
        .fullScreenCover(isPresented: $showFullScreen) {
            FullScreenView()
        }
    }
}
```

### Popover

```swift
struct ContentView: View {
    @State private var showPopover = false
    
    var body: some View {
        Button("Show Popover") {
            showPopover = true
        }
        .popover(isPresented: $showPopover) {
            PopoverContentView()
                .presentationCompactAdaptation(.popover)  // Don't adapt to sheet on iPhone
        }
    }
}
```

For `alert` and `confirmationDialog` API patterns, see `latest-apis.md`.

## Summary Checklist

- [ ] Use `.sheet(item:)` for model-based sheets
- [ ] Sheets own their actions and dismiss internally
- [ ] Use `NavigationStack` with `navigationDestination(for:)` for type-safe navigation
- [ ] Use `NavigationPath` for programmatic navigation
- [ ] Use `NavigationSplitView` for sidebar-driven multi-column layouts
- [ ] Use `Inspector` for trailing-edge supplementary panels
- [ ] Set column widths with `navigationSplitViewColumnWidth(min:ideal:max:)` or `inspectorColumnWidth(min:ideal:max:)`
- [ ] Use appropriate presentation modifiers (sheet, fullScreenCover, popover)
- [ ] Alerts and confirmation dialogs use modern API with actions
- [ ] Avoid passing dismiss/save callbacks to sheets
- [ ] Use enum-based `Identifiable` type with `.sheet(item:)` when presenting multiple sheets
- [ ] Navigation state can be saved/restored when needed
references/soft-deprecation.md
# Handling Soft-Deprecated APIs

This file covers *how to behave* when you encounter soft-deprecated SwiftUI APIs. For the actual list of deprecated-to-modern transitions, see `references/latest-apis.md`.

## What "soft-deprecated" means

A soft-deprecated API is marked deprecated in the SDK headers but with a placeholder deprecation version (`100000.0`) that suppresses compiler warnings. It still compiles and works correctly — it just signals that the API shouldn't be used in new code. Examples include `NavigationView` (use `NavigationStack` / `NavigationSplitView`), `ActionSheet` / `Alert` (use the `.confirmationDialog` / `.alert` modifiers), `MagnificationGesture` (renamed `MagnifyGesture`), and `PresentationMode` (use `\.dismiss`).

Because these still work, treat them as **informational**, not urgent.

## Scoping rule — read this first

All soft-deprecation guidance is scoped to the code you are **directly modifying**. If a file contains several views and the task touches only one, the other views are out of scope.

- Only discuss the view(s) you actually edited.
- Do not mention, flag, or offer to migrate soft-deprecated APIs in code you weren't asked to change — including trailing "while I'm here, want me to migrate `OtherView`?" questions.
- This takes precedence over any prompt asking for "observations" or "other notes."

Mentioning soft-deprecated APIs in untouched code creates noise, distracts from the task, and pressures the user into unrelated work.

## When generating new code

Never introduce a new usage of a soft-deprecated API. If you're unsure whether an API is soft-deprecated, check `references/latest-apis.md` before recommending it — any API that worked in a prior release could have been soft-deprecated since.

## When asked to review, refactor, modernize, or clean up

Point out soft-deprecated APIs in the code under review and suggest the modern replacement. Keep the tone informational — these still compile and run, so frame migration as an improvement, not a bug fix.

## When asked to add a feature or fix a bug

If the view you're editing already uses a soft-deprecated API, **keep it as-is** in your change. Don't silently swap `NavigationView` for `NavigationStack` while adding a search bar — that produces unexpected diffs, risks regressions (state resets, navigation behavior changes), and makes the change harder to review. After delivering the requested change, you may add a brief one-line offer to migrate as a separate step.

If a *different* view in the same file uses a soft-deprecated API, ignore it entirely (see the scoping rule).

## General guidance

- Never introduce new usages of soft-deprecated APIs in code written from scratch.
- Don't proactively scan a codebase for soft-deprecated APIs — only notice them when they appear in code you're directly modifying for the user's request.
- Migrations are real edits with behavioral risk; they belong in their own focused change, not bundled into unrelated work.
references/state-management.md
# SwiftUI State Management Reference

## Table of Contents

- [Property Wrapper Selection Guide](#property-wrapper-selection-guide)
- [@State](#state)
- [Property Wrappers Inside @Observable Classes](#property-wrappers-inside-observable-classes)
- [Make @Observable Property Types Equatable](#make-observable-property-types-equatable)
- [@Observable Dependency Granularity](#observable-dependency-granularity)
- [@Binding](#binding)
- [@FocusState](#focusstate)
- [@StateObject vs @ObservedObject (Legacy - Pre-iOS 17)](#stateobject-vs-observedobject-legacy---pre-ios-17)
- [Don't Pass Values as @State](#dont-pass-values-as-state)
- [@Bindable (iOS 17+)](#bindable-ios-17)
- [let vs var for Passed Values](#let-vs-var-for-passed-values)
- [Environment and Preferences](#environment-and-preferences)
- [Decision Flowchart](#decision-flowchart)
- [State Privacy Rules](#state-privacy-rules)
- [Avoid Nested ObservableObject](#avoid-nested-observableobject)
- [Key Principles](#key-principles)

## Property Wrapper Selection Guide

| Wrapper | Use When | Notes |
|---------|----------|-------|
| `@State` | Internal view state that triggers updates | Must be `private` |
| `@Binding` | Child view needs to modify parent's state | Don't use for read-only |
| `@Bindable` | iOS 17+: View receives `@Observable` object and needs bindings | For injected observables |
| `let` | Read-only value passed from parent | Simplest option |
| `var` | Read-only value that child observes via `.onChange()` | For reactive reads |

**Legacy (Pre-iOS 17):**
| Wrapper | Use When | Notes |
|---------|----------|-------|
| `@StateObject` | View owns an `ObservableObject` instance | Use `@State` with `@Observable` instead |
| `@ObservedObject` | View receives an `ObservableObject` from outside | Never create inline |

## @State

Always mark `@State` properties as `private`. Use for internal view state that triggers UI updates.

```swift
// Correct
@State private var isAnimating = false
@State private var selectedTab = 0
```

**Why Private?** Marking state as `private` makes it clear what's created by the view versus what's passed in. It also prevents accidentally passing initial values that will be ignored (see "Don't Pass Values as @State" below).

### iOS 17+ with @Observable (Preferred)

**Always prefer `@Observable` over `ObservableObject`.** With iOS 17's `@Observable` macro, use `@State` instead of `@StateObject`:

```swift
@Observable
@MainActor  // Always mark @Observable classes with @MainActor
final class DataModel {
    var name = "Some Name"
    var count = 0
}

struct MyView: View {
    @State private var model = DataModel()  // Use @State, not @StateObject

    var body: some View {
        VStack {
            TextField("Name", text: $model.name)
            Stepper("Count: \(model.count)", value: $model.count)
        }
    }
}
```

**Critical**: When a view *owns* an `@Observable` object, always use `@State` -- not `let`. Without `@State`, SwiftUI may recreate the instance when a parent view redraws, losing accumulated state. `@State` tells SwiftUI to preserve the instance across view redraws. Using `@State` also provides bindings directly (no need for `@Bindable`).

**Note**: You may want to mark `@Observable` classes with `@MainActor` to ensure thread safety with SwiftUI, unless your project or package uses Default Actor Isolation set to `MainActor`—in which case, the explicit attribute is redundant and can be omitted.

## Property Wrappers Inside @Observable Classes

**Critical**: The `@Observable` macro transforms stored properties to add observation tracking. Property wrappers (like `@AppStorage`, `@SceneStorage`, `@Query`) also transform properties with their own storage. These two transformations conflict, causing a compiler error.

**Always annotate property-wrapper properties with `@ObservationIgnored` inside `@Observable` classes.**

```swift
@Observable
@MainActor
final class SettingsModel {
    // WRONG - compiler error: property wrappers conflict with @Observable
    // @AppStorage("username") var username = ""

    // CORRECT - @ObservationIgnored prevents the conflict
    @ObservationIgnored @AppStorage("username") var username = ""
    @ObservationIgnored @AppStorage("isDarkMode") var isDarkMode = false

    // Regular stored properties work fine with @Observable
    var isLoading = false
}
```

This applies to **any** property wrapper used inside an `@Observable` class, including but not limited to:
- `@AppStorage`
- `@SceneStorage`
- `@Query` (SwiftData)

**Note**: Since `@ObservationIgnored` disables observation tracking for that property, SwiftUI won't detect changes through the Observation framework. However, property wrappers like `@AppStorage` already notify SwiftUI of changes through their own mechanisms (e.g., UserDefaults KVO), so views still update correctly.

**Never remove `@ObservationIgnored`** from property-wrapper properties in `@Observable` classes — doing so causes a compiler error.

## Make @Observable Property Types Equatable

The `@Observable` macro generates a setter that **skips invalidation when the new value equals the current one** — but only when it can compare them, which means only when the property's type is `Equatable`. Without that conformance, every assignment notifies observing views, even when the value is identical. This is an easy win for properties written frequently with the same value (polling, streaming updates, timers).

```swift
// AVOID: not Equatable — every assignment invalidates, even no-op writes
enum DeliveryStatus { case placed, preparing, shipped, delivered }

// PREFER: Equatable lets the generated setter short-circuit redundant writes
enum DeliveryStatus: Equatable { case placed, preparing, shipped, delivered }
```

This applies to collection properties too: an `Array`/`Set`/`Dictionary` is only `Equatable` when its element type is, so a non-`Equatable` element defeats the short-circuit for the whole collection. (The check is emitted into the generated setter as user code, so it applies on every OS that supports `@Observable` when built with current Xcode.)

This is distinct from `Equatable` *views* (see `references/performance-patterns.md`): that conformance lets SwiftUI skip a view's body; this one lets the model skip notifying observers in the first place.

## @Observable Dependency Granularity

Observation tracks reads at the **property** level, not the field level — so reading any part of a compound property establishes a dependency on the whole thing. Three common traps and their fixes:

- **A computed property establishes dependencies transitively.** `var currentUser: User? { users.first { $0.id == currentID } }` reads `users` in its body, so any view reading `currentUser` depends on the entire `users` array. Renaming the access doesn't change what observation tracks.
- **A struct-typed stored property drags the whole struct.** A view reading `session.user.name` depends on `session.user`; editing any other field of `user` invalidates it.
- **An array/collection read drags the whole collection.** Reading one element establishes a dependency on the entire stored collection.

```swift
// PREFER: cache derived values as stored properties, kept in sync in didSet
@MainActor @Observable
final class AppState {
    var users: [User] = [] { didSet { recomputeCurrentUser() } }
    var currentID: User.ID? { didSet { recomputeCurrentUser() } }

    private(set) var currentUser: User?
    private func recomputeCurrentUser() { currentUser = users.first { $0.id == currentID } }
}
```

For struct-typed properties, expose the fields the views actually read as individual properties on the model (each is then tracked separately). When many rows each observe several fields of their element, model each element as its own `@Observable` and have the parent **persist** the instances — see the per-item view model pattern in `references/performance-patterns.md`. Reading several already-narrow properties from one model is fine and does not need splitting.

## @Binding

Use only when child view needs to **modify** parent's state. If child only reads the value, use `let` instead.

```swift
// Parent
struct ParentView: View {
    @State private var isSelected = false

    var body: some View {
        ChildView(isSelected: $isSelected)
    }
}

// Child - will modify the value
struct ChildView: View {
    @Binding var isSelected: Bool

    var body: some View {
        Button("Toggle") {
            isSelected.toggle()
        }
    }
}
```

### When NOT to use @Binding

- **Don't use `@Binding` for read-only values.** If the child only displays the value and never modifies it, use `let` instead. `@Binding` adds unnecessary overhead and implies a write contract that doesn't exist.

### Prefer KeyPath Bindings Over Closure Bindings

When you need a binding into a model, prefer a KeyPath/subscript-based binding over a hand-written `Binding(get:set:)` closure. A closure binding allocates a new closure each time `body` runs and can't be compared, which can trigger unnecessary invalidations.

```swift
// BAD - closure binding: heap allocation each body pass, defeats comparison
let binding = Binding(
    get: { model[scoreFor: player] },
    set: { model[scoreFor: player] = $0 }
)
PlayerScoreRow(player: player, score: binding)

// GOOD - project through a subscript with @Bindable
@Bindable var model = model
PlayerScoreRow(player: player, score: $model[scoreFor: player])
```

If no suitable subscript exists, add one (a labeled subscript reads as a clean projection into the model). Reserve closure bindings for cases where no key path or subscript can express the transform.

## @FocusState

See `references/focus-patterns.md` for comprehensive focus management guidance including `@FocusState`, `@FocusedValue`, `.focusable()`, default focus, and common pitfalls.

Always mark `@FocusState` as `private`.

## @StateObject vs @ObservedObject (Legacy - Pre-iOS 17)

**Note**: Always prefer `@Observable` with `@State` for iOS 17+.

The key distinction is **ownership**: `@StateObject` when the view **creates and owns** the object; `@ObservedObject` when the view **receives** it from outside.

```swift
// View creates it → @StateObject
@StateObject private var viewModel = MyViewModel()

// View receives it → @ObservedObject
@ObservedObject var viewModel: MyViewModel
```

**Never** create an `ObservableObject` inline with `@ObservedObject` -- it recreates the instance on every view update.

### @StateObject instantiation in View's initializer

Prefer storing the `@StateObject` in the parent view and passing it down. If you must create one in a custom initializer, pass the expression directly to `StateObject(wrappedValue:)` so the `@autoclosure` prevents redundant allocations:

```swift
// Inside a View's init(movie:):
// WRONG — assigning to a local first defeats @autoclosure
let vm = MovieDetailsViewModel(movie: movie)
_viewModel = StateObject(wrappedValue: vm)

// CORRECT — inline expression defers creation
_viewModel = StateObject(wrappedValue: MovieDetailsViewModel(movie: movie))
```

**Modern Alternative**: Use `@Observable` with `@State` instead.

## Don't Pass Values as @State

**Critical**: Never declare passed values as `@State` or `@StateObject`. They only accept an initial value and ignore subsequent updates from the parent.

```swift
// WRONG - child ignores parent updates
struct ChildView: View {
    @State var item: Item  // Shows initial value forever!
    var body: some View { Text(item.name) }
}

// CORRECT - child receives updates
struct ChildView: View {
    let item: Item  // Or @Binding if child needs to modify
    var body: some View { Text(item.name) }
}
```

**Prevention**: Always mark `@State` and `@StateObject` as `private`. This prevents them from appearing in the generated initializer.

## @Bindable (iOS 17+)

Use when receiving an `@Observable` object from outside and needing bindings:

```swift
@Observable
final class UserModel {
    var name = ""
    var email = ""
}

struct ParentView: View {
    @State private var user = UserModel()

    var body: some View {
        EditUserView(user: user)
    }
}

struct EditUserView: View {
    @Bindable var user: UserModel  // Received from parent, needs bindings

    var body: some View {
        Form {
            TextField("Name", text: $user.name)
            TextField("Email", text: $user.email)
        }
    }
}
```

## let vs var for Passed Values

### Use `let` for read-only display

```swift
struct ProfileHeader: View {
    let username: String
    let avatarURL: URL

    var body: some View {
        HStack {
            AsyncImage(url: avatarURL)
            Text(username)
        }
    }
}
```

### Use `var` when reacting to changes with `.onChange()`

```swift
struct ReactiveView: View {
    var externalValue: Int  // Watch with .onChange()
    @State private var displayText = ""

    var body: some View {
        Text(displayText)
            .onChange(of: externalValue) { oldValue, newValue in
                displayText = "Changed from \(oldValue) to \(newValue)"
            }
    }
}
```

## Environment and Preferences

### @Environment

Access environment values provided by SwiftUI or parent views:

```swift
struct MyView: View {
    @Environment(\.colorScheme) private var colorScheme
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        Button("Done") { dismiss() }
            .foregroundStyle(colorScheme == .dark ? .white : .black)
    }
}
```

### Custom Environment Values with @Entry

Use the `@Entry` macro (Xcode 16+, backward compatible to iOS 13) to define custom environment values without boilerplate:

```swift
extension EnvironmentValues {
    @Entry var accentTheme: Theme = .default
}

// Inject
ContentView()
    .environment(\.accentTheme, customTheme)

// Access
struct ThemedView: View {
    @Environment(\.accentTheme) private var theme
}
```

The `@Entry` macro replaces the manual `EnvironmentKey` conformance pattern. It also works with `Transaction`, `ContainerValues`, and `FocusedValues`.

#### Never store closures in custom environment keys

SwiftUI can't reliably compare function values, so a view that reads an environment key holding a closure invalidates on every environment write, even when nothing it cares about changed. Wrapping the closure in a struct doesn't help — the struct still contains an uncomparable closure. The fix is to eliminate the closure: store the data it would have captured as properties and expose the behavior via `callAsFunction` or a method.

```swift
// AVOID: closure in a custom environment key
extension EnvironmentValues {
    @Entry var submit: (String) -> Void = { _ in }
}

// PREFER: a defunctionalized struct (or an @Observable model)
struct SubmitAction { func callAsFunction(_ draft: String) { /* ... */ } }
extension EnvironmentValues {
    @Entry var submit = SubmitAction()
}
```

This rule is for **custom** keys. Framework action types designed to wrap a closure — `\.openURL`, `\.dismiss`, `\.refresh`, and similar — are the intended API and are fine.

#### Keep @Entry default values stable

`@Entry` wraps its default expression in a computed getter, so the default is re-evaluated on every read that falls back to it. If the expression returns a different result each time — a fresh reference like `Model()`, or a runtime value like `Date()` / `UUID()` — readers that use the default invalidate on every unrelated environment write.

```swift
// AVOID: re-allocates Model() on every fallback read
extension EnvironmentValues {
    @Entry var model = Model()
}

// PREFER: back the default with a stable value
extension EnvironmentValues {
    @Entry var model = _defaultModel
    private static let _defaultModel = Model()
}
```

Stable defaults (literals, enum cases, `nil`, or a `static let`-backed instance) need no fix. If readers test the default for an "empty" sentinel, prefer an optional `@Entry var model: Model?` and branch on `if let` instead.

#### Remove unused @Environment reads

Declaring `@Environment(\.someKey)` subscribes the view to that key even if `body` never uses the value, so every write to that key re-evaluates the view for nothing. If the wrapped value isn't referenced anywhere the body reaches, delete the declaration. (The type form `@Environment(Model.self)` is different — observation tracks at the property level, so an unused type-form declaration carries no live invalidation cost.)

### @Environment with @Observable (iOS 17+ - Preferred)

**Always prefer this pattern** for sharing state through the environment:

```swift
@Observable
@MainActor
final class AppState {
    var isLoggedIn = false
}

// Inject
ContentView()
    .environment(AppState())

// Access
struct ChildView: View {
    @Environment(AppState.self) private var appState
}
```

### @EnvironmentObject (Legacy - Pre-iOS 17)

Legacy pattern: inject with `.environmentObject(AppState())`, access with `@EnvironmentObject var appState: AppState`. Prefer `@Observable` with `@Environment` instead.

## Decision Flowchart

```
Is this value owned by this view?
├─ YES: Is it a simple value type?
│       ├─ YES → @State private var
│       └─ NO (class):
│           ├─ Use @Observable → @State private var (mark class @MainActor)
│           └─ Legacy ObservableObject → @StateObject private var
│
└─ NO (passed from parent):
    ├─ Does child need to MODIFY it?
    │   ├─ YES → @Binding var
    │   └─ NO: Does child need BINDINGS to its properties?
    │       ├─ YES (@Observable) → @Bindable var
    │       └─ NO: Does child react to changes?
    │           ├─ YES → var + .onChange()
    │           └─ NO → let
    │
    └─ Is it a legacy ObservableObject from parent?
        └─ YES → @ObservedObject var (consider migrating to @Observable)
```

## State Privacy Rules

**All view-owned state should be `private`:**

```swift
// Correct - clear what's created vs passed
struct MyView: View {
    // Created by view - private
    @State private var isExpanded = false
    @State private var viewModel = ViewModel()
    @AppStorage("theme") private var theme = "light"
    @Environment(\.colorScheme) private var colorScheme
    
    // Passed from parent - not private
    let title: String
    @Binding var isSelected: Bool
    @Bindable var user: User
    
    var body: some View {
        // ...
    }
}
```

**Why**: This makes dependencies explicit and improves code completion for the generated initializer.

## Avoid Nested ObservableObject

**Note**: This limitation only applies to `ObservableObject`. `@Observable` fully supports nested observed objects.

SwiftUI can't track changes through nested `ObservableObject` properties. Workaround: pass the nested object directly to child views as `@ObservedObject`. With `@Observable`, nesting works automatically.

## Key Principles

1. **Always prefer `@Observable` over `ObservableObject`** for new code
2. **Mark `@Observable` classes with `@MainActor` for thread safety (unless using default actor isolation)`**
3. Use `@State` with `@Observable` classes (not `@StateObject`)
4. Use `@Bindable` for injected `@Observable` objects that need bindings
5. **Always mark `@State` and `@StateObject` as `private`**
6. **Never declare passed values as `@State` or `@StateObject`**
7. With `@Observable`, nested objects work fine; with `ObservableObject`, pass nested objects directly to child views
8. **Always add `@ObservationIgnored` to property wrappers** (e.g., `@AppStorage`, `@SceneStorage`, `@Query`) inside `@Observable` classes — they conflict with the macro's property transformation
9. **Prefer `Equatable` types for frequently-written `@Observable` properties** so the generated setter skips redundant invalidations
10. **Never store closures in custom environment keys; keep `@Entry` defaults stable** (no `Model()`/`Date()` expressions)
11. **Prefer KeyPath/subscript bindings over closure bindings**
references/text-patterns.md
# SwiftUI Text Patterns Reference

> For broader localization guidance — String Catalogs, `#bundle` for packages, `LocalizedStringResource`, locale-aware formatting, RTL layout, and translator comments — see `references/localization.md`. This file covers only the verbatim-vs-localized decision for a single `Text`.

## Table of Contents

- [Text Initialization: Verbatim vs Localized](#text-initialization-verbatim-vs-localized)

## Text Initialization: Verbatim vs Localized

**Default: always use `Text("…")`.** Only use `Text(verbatim:)` when explicitly required for a string literal that must not be localized.

```swift
// Localized literal - "Save" is used as the localization key and looked up in Localizable.strings (only if one exists in the project)
Text("Save")

// String variable - bypasses localization automatically; no verbatim needed
let filename: String = model.exportFilename
Text(filename)

// Non-localized literal - use verbatim only when the literal must not be localized
Text(verbatim: "pencil")
```

### Decision Flow

```
Is the input a String variable or dynamic value?
└─ YES → Text(variable)          // bypasses localization automatically

Is the string literal intended for localization?
├─ YES → Text("…")               // default; key looked up in Localizable.strings
└─ NO  → Text(verbatim: "…")     // only when explicitly non-localized
```
references/trace-analysis.md
# Instruments Trace Analysis

Use this reference whenever the user references an Xcode Instruments `.trace`
file. A target SwiftUI source file is **optional** — if provided, you can
cite specific lines; without one, the trace still surfaces view names,
hot symbols, and high-severity events that tell the user where to look.

The bundled parser reads five lanes for SwiftUI responsiveness (Time
Profiler, Hangs, Animation Hitches, SwiftUI updates, and the SwiftUI
cause graph) and exposes three discovery modes (`--list-logs`,
`--list-signposts`, `--fanin-for`) plus a `--window` flag so the agent
can focus analysis on a precise slice of the trace.

## When to invoke

Any of these signals:

- Message contains a path ending in `.trace`.
- User mentions "hangs", "hitches", "jank", "slow view", or performance
  issues alongside an Instruments recording.
- User asks to focus analysis "after / before / between / during" a log
  message or signpost.

Triggering does **not** require a SwiftUI source file. If one is present
you'll ground recommendations in specific lines; if not, base them on the
view names and symbols the trace reveals.

## The three CLI modes

The scripts live alongside this skill at `scripts/` and need only the
Python 3 stdlib + `xctrace` (ships with Xcode at `/usr/bin/xctrace`).

### 1. Full analysis (default)

```bash
python3 "${SKILL_DIR}/scripts/analyze_trace.py" \
  --trace "/path/to/file.trace" \
  --top 10 --top-hitches 5 \
  [--window START_MS:END_MS] \
  --json-only
```

- `--json-only` gives you structured data; omit for JSON + markdown
  summary; `--markdown-only` is for pasting a digest into the chat.
- `--output <path>` writes `<path>.json` and `<path>.md` instead of stdout.
- `--window START_MS:END_MS` (optional) restricts every lane and every
  correlation to that time slice.
- `--run N` selects a specific run when the trace contains more than one
  recording session. Single-run traces don't need it; multi-run traces
  require it and will error with the available run numbers if omitted.
  Use `--list-runs` to dump per-run metadata (template, duration,
  start/end dates, schemas) before analyzing.

### 2. `--list-logs` — find os_log timestamps

```bash
python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace <path> --list-logs \
  [--log-subsystem com.myapp.net] \
  [--log-category "Network"] \
  [--log-type Fault] \
  [--log-message-contains "loaded feed"] \
  [--log-limit 10] \
  [--window START_MS:END_MS]
```

Returns JSON `{ "logs": [...], "count": N }` where each log entry includes
`time_ms`, `type`, `subsystem`, `category`, `process`, and the formatted
`message` (with args substituted) + raw `format_string`. All filters are
AND-combined; `--log-message-contains` is case-insensitive substring match.

### 3. `--list-signposts` — find signpost intervals

```bash
python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace <path> --list-signposts \
  [--signpost-name-contains "ImageDecode"] \
  [--signpost-subsystem com.myapp.feed] \
  [--signpost-category "Rendering"] \
  [--window START_MS:END_MS]
```

Returns JSON `{ "intervals": [...], "events": [...] }`. Intervals are
paired `begin`/`end` signposts with `start_ms`, `end_ms`, `duration_ms`,
`name`, `subsystem`, `category`, `process`, `signpost_id`. Single-point
events (and any unpaired begins) go into `events`. All filters are
AND-combined; `--signpost-name-contains` is case-insensitive substring
match.

### 4. `--fanin-for` — who keeps invalidating this view?

```bash
python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace <path> \
  --fanin-for "TextStyleModifier" \
  [--window START_MS:END_MS] \
  [--top 10]
```

Returns JSON `{ "matches": [...] }`. Each match names a destination node
whose fmt string contains the substring (case-insensitive) and lists its
top incoming source nodes ranked by edge count. Use this after the
`swiftui` lane names an expensive view and you want to know *why it keeps
being invalidated*. For the example above, the top source is
`closure #1 in UserDefaultObserver.Target.GraphAttribute.send()` — the
canonical signature of an `@AppStorage` / `UserDefaults` feedback storm.

## Composition pattern — scoping to a slice

When the user says something like "focus on X", "between A and B", or
"during signpost Y", compose the three modes:

1. **Discover** — call `--list-logs` or `--list-signposts` with filters
   that match the user's description. Pick the right entries.
2. **Build the window** — take `time_ms` (logs) or `start_ms`/`end_ms`
   (intervals) and form `--window START:END`.
3. **Analyse** — call the default mode with `--window`.

Examples:

- *"Focus on the section after the log saying 'loaded feed'."*
  → `--list-logs --log-message-contains "loaded feed"`, take the entry's
  `time_ms`, set window = `[that_ms, end_of_trace_ms]` (or use the trace
  `duration_s × 1000`).
- *"Between the 'begin-sync' log and the 'done-sync' log."*
  → Two `--list-logs` calls (or one with a broader filter), pick the two
  timestamps, set window = `[first, second]`.
- *"During the signpost 'ImageDecode'."*
  → `--list-signposts --signpost-name-contains "ImageDecode"`, pick the
  interval, set window = `[start_ms, end_ms]`.

## JSON shape

```json
{
  "trace": "...",
  "xctrace_version": "26.4 (...)",
  "template": "SwiftUI",
  "duration_s": 14.83,
  "schemas_available": [...],
  "lanes": [
    { "lane": "time-profiler", "available": true, "schema_used": "time-profile",
      "metrics": { "total_samples": N, "total_weight_ms": ms, "processes": [...] },
      "top_offenders": [ { "symbol", "weight_ms", "percent", "samples", "thread" } ] },
    { "lane": "hangs", "available": true, "schema_used": "potential-hangs",
      "metrics": { "count", "total_duration_ms", "worst_duration_ms",
                   "severity_buckets": {"lt_250ms","250ms_1s","gt_1s"} },
      "top_offenders": [ { "start_ms", "duration_ms", "hang_type", "thread" } ] },
    { "lane": "hitches", "available": true, "schema_used": "hitches",
      "metrics": { "count", "total_hitch_ms", "worst_hitch_ms",
                   "narrative_breakdown": {...}, "system_hitches", "app_hitches" },
      "top_offenders": [ { "start_ms", "hitch_duration_ms", "narrative", "is_system" } ] },
    { "lane": "swiftui", "available": true, "schemas_used": [...],
      "metrics": { "total_events", "unique_views", "total_duration_ms",
                   "severity_breakdown": {"Very Low":N,"Moderate":N,"High":N},
                   "update_type_breakdown": {"View Body Updates":N, ...} },
      "top_offenders": [ { "view", "total_ms", "count", "avg_ms" } ],
      "high_severity_events": [ { "view", "severity", "duration_ms", "category",
                                   "update_type", "description" } ] },
    { "lane": "swiftui-causes", "available": true, "schema_used": "swiftui-causes",
      "metrics": { "total_edges", "unique_sources", "unique_destinations",
                   "top_labels": {...} },
      "top_sources":      [ { "source", "edges", "top_destinations": [...] } ],
      "top_destinations": [ { "destination", "edges", "top_sources":      [...] } ] }
  ],
  "correlations": [
    {
      "trigger": { "lane": "hangs"|"hitches", "start_ms", "end_ms", "duration_ms",
                   "hang_type"|"frame_duration_ms" },
      "time_profiler_main_thread": {
        "samples_in_window": N, "samples_on_main": M,
        "main_running_coverage_pct": 0–100,
        "hot_symbols": [ { "symbol", "samples", "weight_ms", "percent_of_main" } ]
      },
      "swiftui_overlapping_updates": [ { "view", "duration_ms", "start_ms" } ]
    }
  ]
}
```

## Interpretation guide

### `main_running_coverage_pct` is the key diagnostic

Time Profiler samples the main thread every ~1ms. For a correlation window
of `N` ms, you'd expect ~`N` main-thread running samples if main were fully
CPU-bound. Coverage is the ratio of observed main-thread samples to that
expectation.

- **< 25% coverage** → main thread was **blocked** (I/O, lock, sync XPC,
  `Task.sleep`, waiting on an actor-isolated call). The `hot_symbols` you
  do see are the moments main *was* executing — look there for the code
  that *initiates* the blocking work, not the work itself. Common fix:
  move to a background executor / `nonisolated` / `Task.detached`.
- **≥ 75% coverage** → main was **CPU-bound** the whole time. `hot_symbols`
  point directly at the expensive work. Common fixes: hoist computation
  out of view bodies, cache derived values, avoid per-frame allocation,
  debounce `onChange`.
- **25–75%** → mix. Usually computation plus intermittent I/O; show both
  hot symbols and note that main was partially blocked.

### High-severity SwiftUI events → reference routing

When `swiftui.high_severity_events[].description` is one of:

| description      | Likely cause              | Route to                            |
|------------------|---------------------------|-------------------------------------|
| `onChange`       | Expensive `.onChange` body | `references/performance-patterns.md`, `references/state-management.md` |
| `Gesture`        | Heavy gesture handler     | `references/performance-patterns.md` |
| `Action Callback`| Button/tap handler work   | `references/performance-patterns.md` |
| `Update`         | View body recomputation   | `references/view-structure.md`, `references/performance-patterns.md` |
| `Creation`       | View init cost            | `references/view-structure.md`      |
| `Layout`         | GeometryReader churn      | `references/layout-best-practices.md` |

### Mapping trace findings to source code

If the user gave you a specific file, use it to confirm/cite. If they didn't, the trace itself tells you which views and symbols to look up.

1. **From `swiftui.top_offenders` and `high_severity_events`**, use the
   `view` string as your search key. If a target file is open, grep it;
   if not, recommend the user grep their project for that type or the
   module name. A partial match (prefix / generic stripping) means it's
   probably a subview.
2. **From `correlations[].time_profiler_main_thread.hot_symbols`**, treat
   symbols starting with the user's module name (or in Swift free-function
   form) as candidates. System frames (`swift_`, `dyld`, `objc_`, `CA*`,
   `CF*`, `NS*`, `__open`, `pthread*`) identify *what* the code was doing
   but the user-code caller one frame up is typically what to fix — say
   so and, if you can, suggest searching the project for callers of the
   equivalent Swift API (e.g. `__open` → `FileHandle` / `Data(contentsOf:)` /
   `JSONDecoder.decode(from: Data)` sites).
3. **From `hitches[].narrative`**, Apple pre-attributes each hitch. The
   string `"Potentially expensive app update(s)"` means SwiftUI blamed the
   app (so user code is in scope); absence of narrative usually means it
   was a system hitch or below the threshold.
4. **Correlating hitches with SwiftUI updates**: the
   `swiftui_overlapping_updates` list on each hitch names the views that
   were actively rendering when the frame dropped. Prioritise those.

### Cause graph: finding *why* updates keep happening

The `swiftui` lane tells you *what* is expensive; the `swiftui-causes`
lane tells you *why* it keeps being triggered. Each edge is "source node
propagated to destination node" in SwiftUI's attribute graph.

Signatures to watch for in `top_sources`:

- **`closure #1 in UserDefaultObserver.Target.GraphAttribute.send()`** —
  an `@AppStorage` / `UserDefaults` write is fanning out to every reader.
  If the destination list contains multiple `@AppStorage <Type>.<prop>`
  entries with thousands of edges each, you have a feedback storm. Fix
  by reading each key once at a high level and passing values down, or
  wrapping settings in a single `@Observable` so only genuine readers
  invalidate. Route to `references/state-management.md` and
  `references/performance-patterns.md`.
- **`EnvironmentWriter: …`** with thousands of edges — a modifier (often
  `.hoverEffect`, custom environment keys) is applied too widely and
  being re-installed during every layout pass. Route to
  `references/view-structure.md`.
- **`View Creation / Reuse`** as the #1 source — the hierarchy is
  replacing children rather than mutating in place. Look for ID
  instability (missing/unstable `.id(…)` on ForEach, type-erased
  `AnyView` wrappers, conditional structure swaps). Route to
  `references/list-patterns.md` and `references/view-structure.md`.

When a specific view in `swiftui.high_severity_events` keeps showing up,
run `--fanin-for "<view name>"` to see the ranked list of sources
invalidating it.

### Picking targets from a full-trace analysis

Prioritise from most actionable to least:

1. **Any `hangs` with `main_running_coverage_pct < 25%`** — these are
   blocking-I/O smells; nearly always fixable by moving work off-main.
2. **Any `hangs` with `main_running_coverage_pct ≥ 75%`** — CPU-bound
   main-thread work; fix the top `hot_symbols`.
3. **`swiftui-causes.top_sources` with > ~1k edges** — structural
   invalidation bugs (feedback storms, over-applied modifiers). These
   are often cheaper to fix than per-view optimisations and collapse
   many downstream high-severity updates at once.
4. **`hitches` with `narrative == "Potentially expensive app update(s)"`**
   and overlapping `swiftui_overlapping_updates` — specific views to
   restructure.
5. **`swiftui.high_severity_events`** — `onChange`, `Gesture`, or `Action
   Callback` with `duration_ms > ~16` are frame-dropping handlers. For
   any that keep firing, run `--fanin-for` to find the source.
6. **`swiftui.top_offenders`** — heaviest views by total body time, even
   without triggering hitches; candidates for view extraction or
   memoisation (`equatable`, `@ViewBuilder` extraction).

## Recommended output format for the user

After running the parser, structure your response as:

1. **One-line summary** — "Found N hangs, worst Wms; K hitches; J high-severity SwiftUI updates."
2. **Root-cause findings** — per prioritised target (see above), one paragraph with the trace evidence (coverage %, hot symbol, overlapping view) and a citation from `references/…` for the fix pattern.
3. **Plan** — numbered, file-specific edits. Cite line numbers in the user's Swift file when you know them. Don't edit the file unless the user asked for edits.
references/trace-recording.md
# Recording an Instruments Trace

Use this reference when the user asks to record a new trace — either to
attach to a running app, launch one fresh, or capture a specific session
of actions they'll perform interactively.

The bundled `scripts/record_trace.py` wraps `xctrace record` with:

- The **SwiftUI** template by default (override with `--template`).
- **Manual stop** via Ctrl+C, a stop-file, or `--time-limit`.
- JSON discovery for devices and templates.
- Normal Python exit codes so an agent can orchestrate.

## Typical flows

### A) Attach to a running app on a connected device

```bash
python3 "${SKILL_DIR}/scripts/record_trace.py" \
  --device "Pol's iPhone" \
  --attach "Helm" \
  --output ~/Desktop/helm-session.trace
```

Leave it running while the user exercises the app. Stop with **Ctrl+C**.

### B) Launch an app and record from the first frame

```bash
python3 "${SKILL_DIR}/scripts/record_trace.py" \
  --device "<UDID>" \
  --launch "/path/to/App.app" \
  --output ~/Desktop/launch.trace
```

Useful for diagnosing cold-start hitches and view-creation cost.

### C) Agent-driven: start in background, stop via stop-file

When you (the agent) are running non-interactively — e.g. via
`Bash run_in_background` — use a stop-file so you can signal the
recording to end cleanly:

```bash
# Start recording (background)
python3 "${SKILL_DIR}/scripts/record_trace.py" \
  --attach Helm --stop-file /tmp/stop-trace \
  --output ~/Desktop/session.trace

# ...user does their thing...

# Stop cleanly (from another shell or tool call)
touch /tmp/stop-trace
```

The script polls every 0.5s for the stop-file, sends SIGINT to xctrace
when it appears, and waits up to 60s for the trace to finalise.

### D) Time-boxed recording

```bash
python3 "${SKILL_DIR}/scripts/record_trace.py" \
  --attach Helm --time-limit 30s --output ~/Desktop/30s.trace
```

xctrace stops itself at the limit.

## Discovery helpers

```bash
# List every connected device, simulator, and the host — JSON.
python3 "${SKILL_DIR}/scripts/record_trace.py" --list-devices

# List all Instruments templates — JSON with a flat list + by-section map.
python3 "${SKILL_DIR}/scripts/record_trace.py" --list-templates
```

Device entries have `kind` (`devices`, `devices offline`, `simulators`),
`name`, `os`, `udid`. Offline devices are known but unplugged / unpaired —
plug them in before recording.

## Picking a template

> **Hard rule: the `SwiftUI` template only populates the SwiftUI lane on a
> real device — a physical iOS/iPadOS device or the host Mac. On the iOS
> Simulator it records but the SwiftUI lane comes back empty.** If the
> chosen UDID falls under the `simulators` kind from `--list-devices`,
> switch to `Time Profiler`. It still gives you Time Profiler + Hangs +
> Animation Hitches, which `analyze_trace.py` analyses and correlates
> normally; only the `swiftui` lane will report `available: false`.

Decision flow:

| Target                                       | Template to pass     |
|----------------------------------------------|----------------------|
| Physical iOS/iPadOS device (connected)       | `SwiftUI` (default)  |
| Host Mac (macOS app, `--all-processes`, etc.)| `SwiftUI` (default)  |
| iOS / iPadOS / watchOS / tvOS Simulator      | `Time Profiler`      |

Always confirm the target kind with `--list-devices` before starting a
recording: entries under `simulators` mean you must switch to Time
Profiler; entries under `devices` (both connected devices and the host
Mac) support the SwiftUI template. Entries under `devices offline` need
the user to connect/unlock/trust the device before recording.

For ad-hoc hang hunting on any target, `Time Profiler` or
`Animation Hitches` alone may be enough.

## Chaining into analysis

The recording script prints `trace written: <path>` on exit. Feed that
path straight into `analyze_trace.py`:

```bash
TRACE=$(python3 "${SKILL_DIR}/scripts/record_trace.py" \
    --attach Helm --stop-file /tmp/stop-trace --output ~/Desktop/session.trace \
    2>&1 | awk '/trace written:/ {print $NF}')
python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace "$TRACE" --json-only
```

If the user wanted a specific scope, combine with `--list-logs` /
`--list-signposts` / `--window` from `references/trace-analysis.md`.

## Failure modes to handle

- **Device offline** — `--list-devices` shows it in `devices offline`.
  Ask the user to connect/unlock the device and retry.
- **Output path exists** — the script refuses to overwrite. Either pick
  a new `--output` or delete the existing bundle.
- **App not running (for `--attach`)** — xctrace exits with an error;
  fall back to `--launch` or tell the user to open the app first.
- **Signing / trust on device** — iOS requires a development build
  signed with the user's team. If xctrace returns a signing error, point
  the user to trust the developer profile on the device.
references/view-structure.md
# SwiftUI View Structure Reference

## Table of Contents

- [View Structure Principles](#view-structure-principles)
- [View File Structure (optional readability suggestion)](#view-file-structure-optional-readability-suggestion)
- [Struct or Method / Computed Property?](#struct-or-method--computed-property)
- [Prefer Modifiers Over Conditional Views](#prefer-modifiers-over-conditional-views)
- [Extract Subviews, Not Computed Properties](#extract-subviews-not-computed-properties)
- [@ViewBuilder](#viewbuilder)
- [Keep View Body Simple and Avoid High-Cost Operations](#keep-view-body-simple-and-avoid-high-cost-operations)
- [Keep View `init` Cheap](#keep-view-init-cheap)
- [Single-Child Group](#single-child-group)
- [When to Extract Subviews](#when-to-extract-subviews)
- [Container View Pattern](#container-view-pattern)
- [Utilize Lazy Containers for Large Data Sets](#utilize-lazy-containers-for-large-data-sets)
- [ZStack vs overlay/background](#zstack-vs-overlaybackground)
- [Compositing Group Before Clipping](#compositing-group-before-clipping)
- [Split State-Driven Parts into Custom View Types](#split-state-driven-parts-into-custom-view-types)
- [Reusable Styling with ViewModifier](#reusable-styling-with-viewmodifier)
- [Skeleton Loading with Redacted Views](#skeleton-loading-with-redacted-views)
- [AnyView](#anyview)
- [UIViewRepresentable Essentials](#uiviewrepresentable-essentials)
- [Troubleshooting](#troubleshooting)
- [Summary Checklist](#summary-checklist)

## View Structure Principles

SwiftUI's diffing algorithm compares view hierarchies to determine what needs updating. Proper view composition directly impacts performance.

## View File Structure (optional readability suggestion)

Property ordering has no effect on correctness or performance, so treat this as a personal/team readability preference rather than a rule. Some developers find a consistent order easier to scan — for example, environment, then state, then passed-in properties, then `init`, body, and helper subviews. Adopt it only if your team wants the consistency; never reorder existing code solely to match it.

```swift
struct ContentView: View {
    // MARK: - Environment Properties
    @Environment(\.colorScheme) var colorScheme

    // MARK: - State Properties
    @Binding var isToggled: Bool
    @State private var viewModel: SomeViewModel

    // MARK: - Private Properties
    private let title: String = "SwiftUI Guide"

    // MARK: - Initializer (if needed)
    init(isToggled: Binding<Bool>) {
        self._isToggled = isToggled
    }

    // MARK: - Body
    var body: some View {
        VStack {
            header
            content
        }
    }

    // MARK: - Computed Subviews
    private var header: some View {
        Text(title).font(.largeTitle).padding()
    }

    private var content: some View {
        VStack {
            Text("Counter: \(counter)")
        }
    }
}
```

## Struct or Method / Computed Property?

If a `View` is intended to be reusable across multiple screens, encapsulate it within a separate `struct`. If its usage is confined to a single context, it can be declared as a function or computed property within the containing `View`.

However, if a view maintains state using `@State`, `@Binding`, `@ObservedObject`, `@Environment`, `@StateObject`, or similar wrappers, it should generally be a separate `struct`.

- For simple, static views: a computed property is acceptable.
- For views requiring parameters: a method is more appropriate, but only when those parameters are stable. If parameters change per-call (e.g. inside a `ForEach` where each call receives a different item), prefer a separate `struct` so SwiftUI can diff inputs and skip body evaluation.
- For reusable, stateful, or logically independent UI sections: prefer a dedicated `struct`.

```swift
struct ContentView: View {
    var titleView: some View {
        Text("Hello from Property")
            .font(.largeTitle)
            .foregroundColor(.blue)
    }

    func messageView(text: String, color: Color) -> some View {
        Text(text)
            .font(.title)
            .foregroundColor(color)
            .padding()
    }

    var body: some View {
        VStack {
            titleView
            messageView(text: "Hello from Method", color: .red)
        }
    }
}
```

## Prefer Modifiers Over Conditional Views

**Prefer "no-effect" modifiers over conditionally including views.** When you introduce a branch, consider whether you're representing multiple views or two states of the same view.

### Use Opacity Instead of Conditional Inclusion

```swift
// Good - same view, different states
SomeView()
    .opacity(isVisible ? 1 : 0)

// Avoid - creates/destroys view identity
if isVisible {
    SomeView()
}
```

**Why**: Conditional view inclusion can cause loss of state, poor animation performance, and breaks view identity. Using modifiers maintains view identity across state changes.

### When Conditionals Are Appropriate

Use conditionals when you truly have **different views**, not different states:

```swift
// Correct - fundamentally different views
if isLoggedIn {
    DashboardView()
} else {
    LoginView()
}

// Correct - optional content
if let user {
    UserProfileView(user: user)
}
```

### Conditional View Modifier Extensions Break Identity

A common pattern is an `if`-based `View` extension for conditional modifiers. This changes the view's return type between branches, which destroys view identity and breaks animations:

```swift
// Problematic -- different return types per branch
extension View {
    @ViewBuilder func `if`<T: View>(_ condition: Bool, transform: (Self) -> T) -> some View {
        if condition {
            transform(self)  // Returns T
        } else {
            self              // Returns Self
        }
    }
}
```

Prefer applying the modifier directly with a ternary or always-present modifier:

```swift
// Good -- same view identity maintained
Text("Hello")
    .opacity(isHighlighted ? 1 : 0.5)

// Good -- modifier always present, value changes
Text("Hello")
    .foregroundStyle(isError ? .red : .primary)
```

When writing new code, never reach for a `.if` modifier. When reviewing existing code that already uses one, point out the identity/animation risk and show the ternary alternative, but don't silently refactor it as part of an unrelated change — swapping it can alter behavior (state resets, transition timing) and belongs in its own focused edit.

## Extract Subviews, Not Computed Properties

A view is SwiftUI's unit of invalidation. When an input changes, SwiftUI re-runs the body of the smallest enclosing **view type** that depends on it — every conditional, modifier chain, and string interpolation in that body, even if only one leaf actually depends on what changed. A computed property or `@ViewBuilder` helper is inlined into the parent's body, so it shares the parent's invalidation boundary and does not reduce update cost; it only reorganizes the code. A separate `View` type with narrow inputs becomes its own boundary and re-runs only when its own inputs change.

This is why "split your body for readability" is also a performance tool — but only when you split into real `View` types, not computed properties.

### The Problem with @ViewBuilder Functions

When you use `@ViewBuilder` functions or computed properties for complex views, the entire function re-executes on every parent state change:

```swift
// BAD - re-executes complexSection() on every tap
struct ParentView: View {
    @State private var count = 0

    var body: some View {
        VStack {
            Button("Tap: \(count)") { count += 1 }
            complexSection()  // Re-executes every tap!
        }
    }

    @ViewBuilder
    func complexSection() -> some View {
        // Complex views that re-execute unnecessarily
        ForEach(0..<100) { i in
            HStack {
                Image(systemName: "star")
                Text("Item \(i)")
                Spacer()
                Text("Detail")
            }
        }
    }
}
```

### The Solution: Separate Structs

Extract to separate `struct` views. SwiftUI can skip their `body` when inputs don't change:

```swift
// GOOD - ComplexSection body SKIPPED when its inputs don't change
struct ParentView: View {
    @State private var count = 0

    var body: some View {
        VStack {
            Button("Tap: \(count)") { count += 1 }
            ComplexSection()  // Body skipped during re-evaluation
        }
    }
}

struct ComplexSection: View {
    var body: some View {
        ForEach(0..<100) { i in
            HStack {
                Image(systemName: "star")
                Text("Item \(i)")
                Spacer()
                Text("Detail")
            }
        }
    }
}
```

### Why This Works

1. SwiftUI compares the `ComplexSection` struct (which has no properties)
2. Since nothing changed, SwiftUI skips calling `ComplexSection.body`
3. The complex view code never executes unnecessarily

### Multi-section detail views

The most common place this rule gets dropped is a detail screen with several distinct sections — `header + gallery + description + reviews`, `header + ingredients + steps`, `hero + specs + related`. The tempting shape is one big view with `private var header: some View`, `private var gallery: some View`, and so on. That shape shares one invalidation boundary, so a change that affects one section re-evaluates all of them. Factor each named section into its own `View` type that takes only the fields it renders, and keep the parent thin — it just composes the sections.

```swift
// PREFER: each section is its own type with narrow inputs.
struct ProductDetailView: View {
    let product: Product

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 24) {
                ProductHeader(name: product.name, price: product.price)
                ProductGallery(images: product.imageURLs)
                ProductDescription(text: product.descriptionText)
                ProductReviews(average: product.averageStars, count: product.reviewCount)
            }
            .padding()
        }
    }
}
```

This generalizes to every `*DetailView`: one `View` type per section, narrow inputs each, a thin parent that composes them. Small `@ViewBuilder` fragments reused two or three times within the same body are still fine — the rule targets factoring done for organization or to manage body length, where a real `View` type does the right thing.

## @ViewBuilder

Use `@ViewBuilder` functions for small, simple sections (a few views, no expensive computation) that don't affect performance. They work particularly well for static content that doesn't depend on any `@State` or `@Binding`, since SwiftUI won't need to diff them independently. Extract to a separate `struct` when the section is complex, depends on state, or needs to be skipped during re-evaluation.

The `@ViewBuilder` attribute is only required when a function or computed property returns multiple different views conditionally, for example through `if` or `switch`:

```swift
@ViewBuilder
private var conditionalView: some View {
    if isExpanded {
        VStack {
            Text("Expanded View")
            Image(systemName: "star")
        }
    } else {
        Text("Collapsed View")
    }
}
```

If every branch returns the same concrete type, `@ViewBuilder` is unnecessary:

```swift
var conditionalText: some View {
    if Bool.random() {
        Text("Hello")
    } else {
        Text("World")
    }
}
```

Prefer `@ViewBuilder` when:

- there is conditional branching between multiple view types
- extracting a separate `struct` would not provide meaningful separation

## Keep View Body Simple and Avoid High-Cost Operations

Refrain from performing complex operations within the `body` of your view. Instead of passing a ready-to-use sequence with filtering, mapping, or sorting directly into `ForEach`, prepare the sequence outside the body.

```swift
// Avoid such things ...
var body: some View {
    List {
        ForEach(model.values.filter { $0 > 0 }, id: \.self) {
            Text(String($0))
                .padding()
        }
    }
}
```

Prefer:

```swift
struct FilteredListView: View {
    private let filteredValues: [Int]

    init(values: [Int]) {
        self.filteredValues = values.filter { $0 > 0 } // Perform filtering once
    }

    var body: some View {
        List {
            content
        }
    }

    private var content: some View {
        ForEach(filteredValues, id: \.self) { value in
            Text(String(value))
                .padding()
        }
    }
}
```

The reason this matters is that the system can call `body` multiple times during a single layout phase. Complex body computation makes those calls more expensive than necessary.

General guidance:

- avoid filtering, sorting, and mapping inline in `body`
- avoid constructing expensive formatters in `body`
- avoid heavy branching in large view trees
- move data preparation into the model layer or dedicated helpers

## Keep View `init` Cheap

A view's `init` runs every time the parent re-evaluates its body, which can be many times per second for views inside `List`, `LazyVStack`, scroll containers, or animated parents. Treat `init` as a constant-time copy of inputs into stored properties. Don't decode JSON, build a `DateFormatter`, touch the file system, or allocate large structures there — that work repeats on every parent body pass even when the inputs are identical.

```swift
// AVOID: decoding and formatting on every init
init(rawJSON: Data, date: Date) {
    self.summary = try! JSONDecoder().decode(WeatherSummary.self, from: rawJSON)
    let formatter = DateFormatter()
    formatter.dateStyle = .medium
    self.formattedDate = formatter.string(from: date)
}

// PREFER: take already-prepared values; format lazily in body
let summary: WeatherSummary
let date: Date

var body: some View {
    VStack {
        Text(summary.headline)
        Text(date, format: .dateTime.day().month().year())  // cached, locale-aware
    }
}
```

If a derived value genuinely needs to be computed once and kept, store it on an `@State`-owned `@Observable` model or compute it asynchronously in `.task` — not in `init`. `init` is not a one-time setup hook; it runs as often as the parent's body does.

## Single-Child Group

`Group { SomeView() }` — a `Group` with exactly one concrete child — wraps the view in an extra `Group<SomeView>` type for no visual benefit. Every modifier chained after it must be type-checked against that wrapper, adding avoidable type-checking overhead in long chains. Drop the `Group` and chain modifiers directly on the child.

```swift
// AVOID: single concrete child wrapped in Group
Group { Text(status) }
    .padding(.horizontal, 8)
    .background(.thinMaterial, in: Capsule())

// PREFER: chain directly on the child
Text(status)
    .padding(.horizontal, 8)
    .background(.thinMaterial, in: Capsule())
```

The rule is specifically about one concrete view. A `Group` whose content is a `ForEach`, multiple sibling views, or an `if`/`else` (which produces `_ConditionalContent`) is doing real work — applying a shared modifier across siblings or both branches — and is fine.

## When to Extract Subviews

Extract complex views into separate subviews when:
- The view has multiple logical sections or responsibilities
- The view contains reusable components
- The view body becomes difficult to read or understand
- You need to isolate state changes for performance
- The view is becoming large (keep views small for better performance)
- The section may evolve independently over time

## Container View Pattern

### Avoid Closure-Based Content

Closures can't be compared, causing unnecessary re-renders:

```swift
// BAD - closure prevents SwiftUI from skipping updates
struct MyContainer<Content: View>: View {
    let content: () -> Content

    var body: some View {
        VStack {
            Text("Header")
            content()  // Always called, can't compare closures
        }
    }
}

// Usage forces re-render on every parent update
MyContainer {
    ExpensiveView()
}
```

### Use @ViewBuilder Property Instead

```swift
// GOOD - view can be compared
struct MyContainer<Content: View>: View {
    @ViewBuilder let content: Content

    var body: some View {
        VStack {
            Text("Header")
            content  // SwiftUI can compare and skip if unchanged
        }
    }
}

// Usage - SwiftUI can diff ExpensiveView
MyContainer {
    ExpensiveView()
}
```

## Utilize Lazy Containers for Large Data Sets

When displaying extensive lists or grids, prefer `LazyVStack`, `LazyHStack`, `LazyVGrid`, or `LazyHGrid`. These containers load views only when they appear on the screen, reducing memory usage and improving performance.

```swift
struct ContentView: View {
    let items = Array(0..<1000)

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(items, id: \.self) { item in
                    Text("Item \(item)")
                }
            }
        }
    }
}
```

Prefer lazy containers when:

- rendering large collections
- row views are non-trivial
- memory usage matters
- the content is inside `ScrollView`

## ZStack vs overlay/background

Use `ZStack` to **compose multiple peer views** that should be layered together and jointly define layout.

Prefer `overlay` / `background` when you’re **decorating a primary view**.  
Not primarily because they don’t affect layout size, but because they **express intent and improve readability**: the view being modified remains the clear layout anchor.

A key difference is **size proposal behavior**:
- In `overlay` / `background`, the child view implicitly adopts the size proposed to the parent when it doesn’t define its own size, making decorative attachments feel natural and predictable.
- In `ZStack`, each child participates independently in layout, and no implicit size inheritance exists. This makes it better suited for peer composition, but less intuitive for simple decoration.

Use `ZStack` (or another container) when the “decoration” **must explicitly participate in layout sizing**—for example, when reserving space, extending tappable/visible bounds, or preventing overlap with neighboring views.

### Examples

```swift
// GOOD - decoration via overlay (layout anchored to button)
Button("Continue") { }
    .overlay(alignment: .trailing) {
        Image(systemName: "lock.fill").padding(.trailing, 8)
    }

// BAD - ZStack when overlay suffices (layout no longer anchored to button)
ZStack(alignment: .trailing) {
    Button("Continue") { }
    Image(systemName: "lock.fill").padding(.trailing, 8)
}

// GOOD - background shape takes parent size
HStack(spacing: 12) { Text("Inbox"); Text("Next") }
    .background { Capsule().strokeBorder(.blue, lineWidth: 2) }
```

## Compositing Group Before Clipping

**Always add `.compositingGroup()` before `.clipShape()` when clipping layered views (`.overlay` or `.background`).** Without it, each layer is antialiased separately and then composited. Where antialiased edges overlap — typically at rounded corners — you get visible color fringes (semi-transparent pixels of different colors blending together).

```swift
let shape = RoundedRectangle(cornerRadius: 16)

// BAD - each layer antialiased separately, producing color fringes at corners
Color.red
    .overlay(.white, in: shape)
    .clipShape(shape)
    .frame(width: 200, height: 150)

// GOOD - layers composited first, antialiasing applied once during clipping
Color.red
    .overlay(.white, in: .rect)
    .compositingGroup()
    .clipShape(shape)
    .frame(width: 200, height: 150)
```

`.compositingGroup()` forces all child layers to be rendered into a single offscreen buffer before the clip is applied. This means antialiasing only happens once — on the final composited result — eliminating the fringe artifacts.

## Split State-Driven Parts into Custom View Types

Large views often depend on multiple independent state sources. If a single view body depends on all of them, then any state change can cause the entire body to re-evaluate.

```swift
struct BigAndComplicatedView: View {
    @State private var counter = 0
    @State private var isToggled = false
    @StateObject private var viewModel = SomeViewModel()

    let title = "Big and Complicated View"

    var body: some View {
        VStack {
            Text(title)
                .font(.largeTitle)

            Text("Counter: \(counter)")
                .font(.title)

            Toggle("Enable Feature", isOn: $isToggled)
                .padding()

            Button("Increment Counter") {
                counter += 1
            }

            Text("ViewModel Data: \(viewModel.data)")
                .padding()

            Button("Fetch Data") {
                viewModel.fetchData()
            }
        }
    }
}
```

### Better: Split Into Smaller Components

```swift
struct BigAndComplicatedView: View {
    @State private var counter = 0
    @State private var isToggled = false
    @StateObject private var viewModel = SomeViewModel()

    var body: some View {
        VStack {
            titleView
            CounterView(counter: $counter)
            ToggleView(isToggled: $isToggled)
            ViewModelDataView(data: viewModel.data) {
                viewModel.updateData()
            }
            .equatable()
        }
    }

    private var titleView: some View {
        Text("Big and Complicated View")
            .font(.largeTitle)
    }
}
```

Why this is better:

- changing `counter` only affects `CounterView`
- toggling only affects `ToggleView`
- updating the model data only affects `ViewModelDataView`

### Notes on Equatable

Using `Equatable` for a view is not a universal best practice, but it can be useful in targeted cases where:

- the input is small and well-defined
- the comparison logic is meaningful
- you want to reduce unnecessary body evaluation for a specific subtree

Do not use `Equatable` as a blanket optimization technique.

## Reusable Styling with ViewModifier

Extract repeated modifier combinations into a `ViewModifier` struct. Expose via a `View` extension for autocompletion:

```swift
private struct CardStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .background(Color(.secondarySystemBackground))
            .clipShape(.rect(cornerRadius: 12))
    }
}

extension View {
    func cardStyle() -> some View {
        modifier(CardStyle())
    }
}
```

### Custom ButtonStyle

Use the `ButtonStyle` protocol for reusable button designs. Use `PrimitiveButtonStyle` only when you need custom interaction handling (e.g., simultaneous gestures):

```swift
struct PrimaryButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .bold()
            .foregroundStyle(.white)
            .padding(.horizontal, 16)
            .padding(.vertical, 8)
            .background(Color.accentColor)
            .clipShape(Capsule())
            .scaleEffect(configuration.isPressed ? 0.95 : 1)
            .animation(.smooth, value: configuration.isPressed)
    }
}
```

### Discoverability with Static Member Lookup

Make custom styles and modifiers discoverable via leading-dot syntax:

```swift
extension ButtonStyle where Self == PrimaryButtonStyle {
    static var primary: PrimaryButtonStyle { .init() }
}

// Usage: .buttonStyle(.primary)
```

This pattern works for any SwiftUI style protocol (`ButtonStyle`, `ListStyle`, `ToggleStyle`, etc.).

## Skeleton Loading with Redacted Views

Use `.redacted(reason: .placeholder)` to show skeleton views while data loads. Use `.unredacted()` to opt out specific views:

```swift
VStack(alignment: .leading) {
    Text(article?.title ?? String(repeating: "X", count: 20))
        .font(.headline)
    Text(article?.author ?? String(repeating: "X", count: 12))
        .font(.subheadline)
    Text("SwiftLee")
        .font(.caption)
        .unredacted()
}
.redacted(reason: article == nil ? .placeholder : [])
```

Apply `.redacted` on a container to redact all children at once.

## AnyView

`AnyView` is type erasure. SwiftUI uses structural identity based on type information to determine when views should be updated.

```swift
private var nameView: some View {
    if isEditable {
        TextField("Your name", text: $name)
    } else {
        Text(name)
    }
}
```

Avoid patterns like:

```swift
private var nameView: some View {
    if isEditable {
        return AnyView(TextField("Your name", text: $name))
    } else {
        return AnyView(Text(name))
    }
}
```

Because `AnyView` erases type information, SwiftUI loses some optimization opportunities. Prefer `@ViewBuilder` or conditional branches with concrete view types.

Use `AnyView` only when type erasure is truly necessary for API design.

## UIViewRepresentable Essentials

When bridging UIKit views into SwiftUI:

- `makeUIView(context:)` is called **once** to create the UIKit view
- `updateUIView(_:context:)` is called on **every SwiftUI redraw** to sync state
- The representable struct itself is **recreated on every redraw** -- avoid heavy work in its init
- Use a `Coordinator` for delegate callbacks and two-way communication

```swift
struct MapView: UIViewRepresentable {
    let coordinate: CLLocationCoordinate2D

    func makeUIView(context: Context) -> MKMapView {
        let map = MKMapView()
        map.delegate = context.coordinator
        return map
    }

    func updateUIView(_ map: MKMapView, context: Context) {
        map.setCenter(coordinate, animated: true)
    }

    func makeCoordinator() -> Coordinator { Coordinator() }

    class Coordinator: NSObject, MKMapViewDelegate { }
}
```

## Troubleshooting

### Debug SwiftUI Renderings

> See `references/performance-patterns.md` (item #8) for the `_printChanges()` vs `_logChanges()` comparison and the `@self`/`@identity` output meaning. The snippets below show the call sites.

If it is needed to debug render cycles and read console output you can leverage the `_printChanges()` or `_logChanges()` methods on `View`. These methods print information about when the view is being evaluated and what changes are triggering updates. This can be very helpful when your view body is called multiple times and you want to know why.

```swift
struct ContentView: View {
    @State private var counter: Int = 99

    init() {
        print(Self.self, #function)
    }

    var body: some View {
        let _ = Self._printChanges()

        VStack {
            Text("Counter: \(counter)")
            Button {
                counter += 1
            } label: {
                Text("Counter +1")
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}
```

As an alternative to `Self._printChanges()`, you can use `_logChanges()`

```swift
struct ContentView: View {
    @State private var counter: Int = 99

    var body: some View {
        let _ = Self._logChanges()

        VStack {
            Text("Counter: \(counter)")
            Button {
                counter += 1
            } label: {
                Text("Counter +1")
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}
```

Use these tools only for debugging and remove them from production code.

### Handling "The Compiler Is Unable to Type-Check This Expression in Reasonable Time"

If you encounter:

> The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions

it is often caused by overly complex view structures or expressions.

Ways to fix it:

- break large expressions into smaller computed values
- extract subviews
- split long modifier chains
- simplify nested generics and builders
- avoid huge inline closures

## Summary Checklist

- [ ] Prefer modifiers over conditional views for state changes
- [ ] Avoid `if`-based conditional modifier extensions (they break view identity)
- [ ] Extract complex views into separate subviews, not computed properties
- [ ] Keep views small for readability and performance
- [ ] Use `@ViewBuilder` only where it actually adds value
- [ ] Avoid heavy filtering, mapping, sorting, or formatter creation inside `body`
- [ ] Keep view `init` cheap (no decoding/formatting/allocation; it runs on every parent body pass)
- [ ] Avoid single-child `Group { OneView() }` (chain modifiers directly on the child)
- [ ] Use lazy containers for large data sets
- [ ] Container views use `@ViewBuilder let content: Content`
- [ ] Prefer `overlay` / `background` for decoration and `ZStack` for peer composition
- [ ] `.compositingGroup()` before `.clipShape()` on layered views to avoid antialiasing fringes
- [ ] Split state-heavy areas into smaller view types
- [ ] Extract repeated styling into `ViewModifier` or `ButtonStyle`
- [ ] Expose reusable styles via static member lookup when it improves discoverability
- [ ] Use `.redacted(reason: .placeholder)` for loading skeletons
- [ ] Avoid `AnyView` unless type erasure is truly needed
- [ ] In `UIViewRepresentable`, keep heavy work out of struct init
- [ ] Use `_printChanges()` / `_logChanges()` to debug rendering behavior
- [ ] Break up overly complex expressions when the compiler struggles
scripts/analyze_trace.py
#!/usr/bin/env python3
"""Analyze an Xcode Instruments .trace file and emit JSON + markdown.

Primary modes:
  (default)           Full four-lane analysis + cross-lane correlations.
  --list-logs         Dump os_log entries (optionally filtered) as JSON so an
                      agent can locate a focus window by log content.
  --list-signposts    Dump os_signpost intervals + point events as JSON.

Windowing:
  --window START_MS:END_MS restricts every lane to that slice of the trace.
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

from instruments_parser import (
    causes,
    correlate,
    events,
    hangs,
    hitches,
    summary,
    swiftui,
    time_profiler,
    xctrace,
)


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Analyze an Instruments .trace file.",
    )
    parser.add_argument("--trace", required=True, type=Path)
    parser.add_argument(
        "--output",
        type=Path,
        help="Base path; writes <output>.json and <output>.md",
    )
    parser.add_argument("--top", type=int, default=10, help="Top-N per lane")
    parser.add_argument(
        "--top-hitches",
        type=int,
        default=5,
        help="Correlate only the N worst hitches (avoid flooding output).",
    )
    parser.add_argument(
        "--window",
        type=str,
        default=None,
        help="Restrict analysis to a time slice, e.g. --window 10400:11700 (ms).",
    )
    parser.add_argument(
        "--run",
        type=int,
        default=None,
        help="Which run to analyze (1-based). Required for traces with >1 run.",
    )
    parser.add_argument(
        "--list-runs", action="store_true",
        help="Emit per-run metadata as JSON (use this to discover available runs).",
    )

    # Mode flags (mutually exclusive with full analysis)
    mode_group = parser.add_argument_group("Discovery modes")
    mode_group.add_argument(
        "--list-logs", action="store_true",
        help="Emit os_log entries as JSON (use filter flags below).",
    )
    mode_group.add_argument(
        "--list-signposts", action="store_true",
        help="Emit os_signpost intervals + events as JSON.",
    )
    mode_group.add_argument("--log-subsystem", type=str, default=None)
    mode_group.add_argument("--log-category", type=str, default=None)
    mode_group.add_argument(
        "--log-type", type=str, default=None,
        help="e.g. Fault, Error, Default, Info, Debug",
    )
    mode_group.add_argument(
        "--log-message-contains", type=str, default=None,
        help="Case-insensitive substring match on the message / format string.",
    )
    mode_group.add_argument(
        "--log-limit", type=int, default=None,
        help="Cap number of log entries returned (applied after all filters).",
    )
    mode_group.add_argument(
        "--signpost-name-contains", type=str, default=None,
        help="Case-insensitive substring match on signpost name.",
    )
    mode_group.add_argument("--signpost-subsystem", type=str, default=None)
    mode_group.add_argument("--signpost-category", type=str, default=None)
    mode_group.add_argument(
        "--fanin-for", type=str, default=None,
        help="Emit incoming cause-graph sources for destinations whose fmt "
             "contains this substring. Case-insensitive.",
    )

    fmt_group = parser.add_mutually_exclusive_group()
    fmt_group.add_argument("--json-only", action="store_true")
    fmt_group.add_argument("--markdown-only", action="store_true")

    args = parser.parse_args(argv)

    # The discovery modes aren't in a mutually_exclusive_group because they
    # live alongside their sub-filters in the same argparse group; enforce the
    # constraint by hand so an agent gets a clear error instead of silent
    # precedence.
    active_modes = sum([
        args.list_runs,
        args.list_logs,
        args.list_signposts,
        bool(args.fanin_for),
    ])
    if active_modes > 1:
        parser.error(
            "--list-runs, --list-logs, --list-signposts, and --fanin-for are "
            "mutually exclusive; pick one per invocation."
        )

    trace = args.trace
    if not trace.exists():
        print(f"error: trace not found: {trace}", file=sys.stderr)
        return 2

    info = xctrace.toc(trace)
    window_ns = _parse_window(args.window)

    if args.list_runs:
        sys.stdout.write(json.dumps({
            "xctrace_version": info.xctrace_version,
            "runs": [
                {
                    "number": r.number,
                    "template": r.template_name,
                    "duration_s": r.duration_s,
                    "start_date": r.start_date,
                    "end_date": r.end_date,
                    "schemas": sorted(r.schemas),
                }
                for r in info.runs
            ],
        }, indent=2))
        sys.stdout.write("\n")
        return 0

    run_info = _resolve_run(info, args.run)
    if run_info is None:
        return 2
    run_number = run_info.number

    if args.list_logs:
        out = events.list_logs(
            trace, run_info.schemas,
            subsystem=args.log_subsystem,
            category=args.log_category,
            message_contains=args.log_message_contains,
            message_type=args.log_type,
            limit=args.log_limit,
            window_ns=window_ns,
            run=run_number,
        )
        sys.stdout.write(json.dumps({"logs": out, "count": len(out)}, indent=2))
        sys.stdout.write("\n")
        return 0

    if args.list_signposts:
        sp = events.list_signposts(
            trace, run_info.schemas,
            name_contains=args.signpost_name_contains,
            subsystem=args.signpost_subsystem,
            category=args.signpost_category,
            window_ns=window_ns,
            run=run_number,
        )
        sys.stdout.write(json.dumps(sp, indent=2))
        sys.stdout.write("\n")
        return 0

    if args.fanin_for:
        fanin = causes.fanin_for(
            trace, run_info.schemas,
            destination_contains=args.fanin_for,
            top_k=args.top,
            window=window_ns,
            run=run_number,
        )
        sys.stdout.write(json.dumps(fanin, indent=2))
        sys.stdout.write("\n")
        return 0

    # Full five-lane analysis
    schemas = run_info.schemas
    lanes_out = {
        "time-profiler":  time_profiler.analyze(trace, schemas, top_n=args.top, window=window_ns, run=run_number),
        "hangs":          hangs.analyze(trace, schemas, top_n=args.top, window=window_ns, run=run_number),
        "hitches":        hitches.analyze(trace, schemas, top_n=args.top, window=window_ns, run=run_number),
        "swiftui":        swiftui.analyze(trace, schemas, top_n=args.top, window=window_ns, run=run_number),
        "swiftui-causes": causes.analyze(trace, schemas, top_n=args.top, window=window_ns, run=run_number),
    }
    correlations = correlate.build(
        lanes_out, top_hitches=args.top_hitches, top_symbols=5
    )
    public_lanes = [_strip_internal(l) for l in lanes_out.values()]

    result: dict = {
        "trace": str(trace),
        "xctrace_version": info.xctrace_version,
        "run": run_number,
        "runs_available": [r.number for r in info.runs],
        "template": run_info.template_name,
        "duration_s": run_info.duration_s,
        "start_date": run_info.start_date,
        "end_date": run_info.end_date,
        "schemas_available": sorted(run_info.schemas),
        "lanes": public_lanes,
        "correlations": correlations,
    }
    if window_ns is not None:
        result["window_ms"] = {
            "start": window_ns[0] / 1_000_000,
            "end": window_ns[1] / 1_000_000,
        }

    md = summary.render(result)

    if args.output:
        json_path = args.output.with_suffix(".json")
        md_path = args.output.with_suffix(".md")
        json_path.write_text(json.dumps(result, indent=2))
        md_path.write_text(md)
        print(f"wrote {json_path}")
        print(f"wrote {md_path}")
        return 0

    if args.markdown_only:
        sys.stdout.write(md)
    elif args.json_only:
        sys.stdout.write(json.dumps(result, indent=2))
        sys.stdout.write("\n")
    else:
        sys.stdout.write(json.dumps(result, indent=2))
        sys.stdout.write("\n---\n")
        sys.stdout.write(md)
    return 0


def _resolve_run(info, requested: int | None):
    """Pick a run from the trace.

    If `requested` is given, return that run or None on miss (with a friendly
    error). If unset and the trace has exactly one run, default to it. If
    unset and there are multiple runs, error out so the agent picks
    explicitly — silently picking run 1 lost data for the user.
    """
    if not info.runs:
        print("error: trace has no runs", file=sys.stderr)
        return None
    if requested is not None:
        try:
            return info.get_run(requested)
        except KeyError as e:
            print(f"error: {e}", file=sys.stderr)
            return None
    if len(info.runs) == 1:
        return info.runs[0]
    available = ", ".join(str(r.number) for r in info.runs)
    print(
        f"error: trace has {len(info.runs)} runs ({available}); pass --run N. "
        f"Use --list-runs to see per-run metadata.",
        file=sys.stderr,
    )
    return None


def _parse_window(spec: str | None) -> tuple[int, int] | None:
    if not spec:
        return None
    if ":" not in spec:
        raise SystemExit(f"--window expects START_MS:END_MS, got {spec!r}")
    start_s, end_s = spec.split(":", 1)
    try:
        start_ms = float(start_s)
        end_ms = float(end_s)
    except ValueError as e:
        raise SystemExit(f"--window: {e}")
    if end_ms < start_ms:
        raise SystemExit("--window: end_ms must be >= start_ms")
    return (int(start_ms * 1_000_000), int(end_ms * 1_000_000))


def _strip_internal(lane: dict) -> dict:
    return {k: v for k, v in lane.items() if not k.startswith("_")}


if __name__ == "__main__":
    sys.exit(main())
scripts/instruments_parser/__init__.py
"""Parsers for Xcode Instruments .trace files via xctrace export."""
scripts/instruments_parser/causes.py
"""SwiftUI cause-graph lane (`swiftui-causes` schema).

Instruments emits one row per edge in SwiftUI's dependency graph: every time
a source node (a state change, user defaults observer, system event, etc.)
propagates to a destination node (a body evaluation, layout, creation), a
row is written with both endpoints as metadata values.

This lane aggregates those edges two ways:

- **By source node** — which attribute graph nodes are driving the most
  updates overall. The canonical "why is my app thrashing?" view; a
  `UserDefaultObserver.send()` showing up with 11k outgoing edges is a
  feedback storm.
- **By destination node** — which views/modifiers receive the most
  invalidations, and from whom. Use this to trace a hot view back to the
  source that keeps poking it.

The analyzer's main lane (`swiftui`) tells you *what* updates are
expensive; this lane tells you *why* they keep happening.
"""
from __future__ import annotations

from collections import Counter, defaultdict
from pathlib import Path
from typing import Any

from . import xctrace, xml_utils

SCHEMA = "swiftui-causes"

# Metadata nodes render as space-separated field dumps ("A gray icon n/a n/a").
# We aggregate on the full fmt string so callers can spot specific edges like
# "@AppStorage TextStyleModifier.fontOption", but also expose the short head
# ("@AppStorage", "Creation of App", ...) for coarser grouping.


def analyze(
    trace_path: Path,
    toc_schemas: frozenset[str],
    top_n: int = 10,
    top_k_per_node: int = 5,
    window: tuple[int, int] | None = None,
    run: int = 1,
) -> dict[str, Any]:
    if SCHEMA not in toc_schemas:
        return {
            "lane": "swiftui-causes",
            "available": False,
            "notes": [
                "SwiftUI causes data not present (requires SwiftUI template on a real device).",
            ],
        }

    xml_bytes = xctrace.export_schema(trace_path, SCHEMA, run=run)
    stream = xml_utils.RowStream(xml_bytes)

    source_edges: Counter[str] = Counter()
    destination_edges: Counter[str] = Counter()
    fanout: dict[str, Counter[str]] = defaultdict(Counter)
    fanin: dict[str, Counter[str]] = defaultdict(Counter)
    label_counts: Counter[str] = Counter()
    total_edges = 0

    for row in stream:
        time_el = xml_utils.first_present(row, "timestamp", "time")
        if time_el is not None:
            t_ns = xml_utils.int_text(stream.resolve(time_el))
            if t_ns is not None and not xml_utils.in_window(t_ns, window):
                continue

        src = _fmt(row, stream, "source-node")
        dst = _fmt(row, stream, "destination-node")
        if not src or not dst:
            continue

        source_edges[src] += 1
        destination_edges[dst] += 1
        fanout[src][dst] += 1
        fanin[dst][src] += 1

        label = _fmt(row, stream, "label")
        if label:
            label_counts[label] += 1

        total_edges += 1

    top_sources = [
        {
            "source": src,
            "edges": count,
            "top_destinations": [
                {"destination": d, "edges": c}
                for d, c in fanout[src].most_common(top_k_per_node)
            ],
        }
        for src, count in source_edges.most_common(top_n)
    ]

    top_destinations = [
        {
            "destination": dst,
            "edges": count,
            "top_sources": [
                {"source": s, "edges": c}
                for s, c in fanin[dst].most_common(top_k_per_node)
            ],
        }
        for dst, count in destination_edges.most_common(top_n)
    ]

    return {
        "lane": "swiftui-causes",
        "available": True,
        "schema_used": SCHEMA,
        "metrics": {
            "total_edges": total_edges,
            "unique_sources": len(source_edges),
            "unique_destinations": len(destination_edges),
            "top_labels": dict(label_counts.most_common(top_n)),
        },
        "top_sources": top_sources,
        "top_destinations": top_destinations,
        "notes": [],
    }


def fanin_for(
    trace_path: Path,
    toc_schemas: frozenset[str],
    destination_contains: str,
    top_k: int = 10,
    window: tuple[int, int] | None = None,
    run: int = 1,
) -> dict[str, Any]:
    """Return the top source nodes feeding any destination whose fmt string
    contains `destination_contains` (case-insensitive substring).

    Used when the agent has a suspect view from the `swiftui` lane and wants
    to know *who keeps invalidating it*. Does a full pass over the causes
    schema each time — cheap enough at typical trace sizes.
    """
    if SCHEMA not in toc_schemas:
        return {"available": False, "matches": []}

    needle = destination_contains.lower()
    xml_bytes = xctrace.export_schema(trace_path, SCHEMA, run=run)
    stream = xml_utils.RowStream(xml_bytes)

    matches: dict[str, Counter[str]] = defaultdict(Counter)
    totals: Counter[str] = Counter()

    for row in stream:
        time_el = xml_utils.first_present(row, "timestamp", "time")
        if time_el is not None:
            t_ns = xml_utils.int_text(stream.resolve(time_el))
            if t_ns is not None and not xml_utils.in_window(t_ns, window):
                continue

        dst = _fmt(row, stream, "destination-node")
        if not dst or needle not in dst.lower():
            continue
        src = _fmt(row, stream, "source-node")
        if not src:
            continue

        matches[dst][src] += 1
        totals[dst] += 1

    out = []
    for dst, count in totals.most_common(top_k):
        out.append({
            "destination": dst,
            "total_incoming_edges": count,
            "top_sources": [
                {"source": s, "edges": c}
                for s, c in matches[dst].most_common(top_k)
            ],
        })
    return {"available": True, "matches": out}


def _fmt(row, stream, key: str) -> str | None:
    el = row.get(key)
    if el is None:
        return None
    resolved = stream.resolve(el)
    return resolved.get("fmt") or xml_utils.str_text(resolved)
scripts/instruments_parser/correlate.py
"""Cross-lane correlation: for each hang and top-N worst hitches, aggregate
Time Profiler samples and SwiftUI updates whose timestamps fall inside the
event window [start, start+duration]. Uses bisect so lookups stay O(log N)
per event.
"""
from __future__ import annotations

from bisect import bisect_left, bisect_right
from collections import defaultdict
from typing import Any


def build(lanes: dict[str, dict], top_hitches: int = 5, top_symbols: int = 5) -> list[dict]:
    """Produce a list of correlation entries.

    `lanes` is a dict keyed by lane name (time-profiler, hangs, hitches,
    swiftui) of their analyzer outputs.
    """
    tp = lanes.get("time-profiler")
    hangs = lanes.get("hangs")
    hitches = lanes.get("hitches")
    swiftui = lanes.get("swiftui")

    tp_index = _build_time_profile_index(tp)
    sui_events = (swiftui or {}).get("_events") if swiftui and swiftui.get("available") else None

    correlations: list[dict] = []

    if hangs and hangs.get("available"):
        for h in hangs.get("_events", []):
            correlations.append(
                _correlate_event(
                    trigger_lane="hangs",
                    start_ns=h["start_ns"],
                    end_ns=h["end_ns"],
                    extra={"hang_type": h["hang_type"]},
                    tp_index=tp_index,
                    sui_events=sui_events,
                    top_symbols=top_symbols,
                )
            )

    if hitches and hitches.get("available"):
        worst_hitches = hitches.get("_events", [])[:top_hitches]
        for hi in worst_hitches:
            correlations.append(
                _correlate_event(
                    trigger_lane="hitches",
                    start_ns=hi["start_ns"],
                    end_ns=hi["end_ns"],
                    extra={
                        "frame_duration_ms": hi["frame_duration_ms"],
                        "hitch_duration_ms": hi["hitch_duration_ms"],
                    },
                    tp_index=tp_index,
                    sui_events=sui_events,
                    top_symbols=top_symbols,
                )
            )

    return correlations


# --- Internal -------------------------------------------------------------

def _build_time_profile_index(tp: dict | None):
    if not tp or not tp.get("available"):
        return None
    samples = tp.get("_samples") or []
    if not samples:
        return None
    # Samples are already sorted by time in time_profiler.analyze.
    times = [s["time_ns"] for s in samples]
    return {"times": times, "samples": samples}


def _correlate_event(
    trigger_lane: str,
    start_ns: int,
    end_ns: int,
    extra: dict,
    tp_index: dict | None,
    sui_events: list[dict] | None,
    top_symbols: int,
) -> dict[str, Any]:
    entry: dict[str, Any] = {
        "trigger": {
            "lane": trigger_lane,
            "start_ms": round(start_ns / 1_000_000, 2),
            "end_ms": round(end_ns / 1_000_000, 2),
            "duration_ms": round((end_ns - start_ns) / 1_000_000, 2),
            **extra,
        },
    }

    if tp_index is not None:
        tp = _time_profile_hot_symbols(
            tp_index, start_ns, end_ns, top_symbols
        )
        duration_ns = end_ns - start_ns
        # Sample rate is 1ms/sample on standard Time Profiler. If the window
        # is N ms long we'd expect ~N main-thread samples if main was fully
        # running; fewer means main was blocked (I/O, lock, etc.).
        expected_if_running = max(1, duration_ns // 1_000_000)
        coverage_pct = min(100.0, 100.0 * tp["samples_main"] / expected_if_running)
        entry["time_profiler_main_thread"] = {
            "samples_in_window": tp["samples_total"],
            "samples_on_main": tp["samples_main"],
            "main_running_coverage_pct": round(coverage_pct, 1),
            "hot_symbols": tp["hot_symbols"],
        }

    if sui_events is not None:
        sui_overlap = _swiftui_overlaps(sui_events, start_ns, end_ns)
        entry["swiftui_overlapping_updates"] = sui_overlap

    return entry


def _time_profile_hot_symbols(
    tp_index: dict, start_ns: int, end_ns: int, top_n: int
) -> dict:
    """Return main-thread hot symbols in the given window.

    Hang/hitch/SwiftUI correlations are all main-thread responsiveness
    problems, so worker-thread symbols are noise. We also return a coverage
    metric — when main was blocked on I/O or a lock, the window will have
    far fewer samples than its duration would predict, and that signal is
    what tells the agent "this was blocked, not CPU-bound".
    """
    times = tp_index["times"]
    samples = tp_index["samples"]
    lo = bisect_left(times, start_ns)
    hi = bisect_right(times, end_ns)
    window = samples[lo:hi]
    if not window:
        return {"samples_total": 0, "samples_main": 0, "hot_symbols": []}

    main_samples = [s for s in window if s["is_main"]]
    weight_by_symbol: dict[str, int] = defaultdict(int)
    count_by_symbol: dict[str, int] = defaultdict(int)
    for s in main_samples:
        weight_by_symbol[s["leaf_symbol"]] += s["weight_ns"]
        count_by_symbol[s["leaf_symbol"]] += 1
    total_weight = sum(weight_by_symbol.values()) or 1

    ranked = sorted(weight_by_symbol.items(), key=lambda kv: kv[1], reverse=True)
    hot = []
    for symbol, weight in ranked[:top_n]:
        hot.append({
            "symbol": symbol,
            "samples": count_by_symbol[symbol],
            "weight_ms": round(weight / 1_000_000, 2),
            "percent_of_main": round(100.0 * weight / total_weight, 2),
        })
    return {
        "samples_total": len(window),
        "samples_main": len(main_samples),
        "hot_symbols": hot,
    }


def _swiftui_overlaps(
    events: list[dict], start_ns: int, end_ns: int
) -> list[dict]:
    # Events aren't guaranteed sorted by start_ns here (we sort by duration in
    # swiftui.analyze). Linear scan; SwiftUI event counts are typically small.
    out: list[dict] = []
    for e in events:
        if e["end_ns"] < start_ns or e["start_ns"] > end_ns:
            continue
        out.append({
            "view": e["view"],
            "duration_ms": e["duration_ms"],
            "start_ms": e["start_ms"],
        })
    # Worst first.
    out.sort(key=lambda x: x["duration_ms"], reverse=True)
    return out[:10]
scripts/instruments_parser/events.py
"""Discovery helpers for os_log messages and os_signpost intervals.

These let an agent locate a focus window (e.g. "after the log saying X",
"during signpost Y") before running the main lane analysis.
"""
from __future__ import annotations

from pathlib import Path
from typing import Any

from . import xctrace, xml_utils

OS_LOG_SCHEMA = "os-log"
OS_SIGNPOST_SCHEMA = "os-signpost"
OS_SIGNPOST_INTERVAL_SCHEMA = "os-signpost-interval"


def list_logs(
    trace_path: Path,
    toc_schemas: frozenset[str],
    subsystem: str | None = None,
    category: str | None = None,
    message_contains: str | None = None,
    message_type: str | None = None,
    limit: int | None = None,
    window_ns: tuple[int, int] | None = None,
    run: int = 1,
) -> list[dict[str, Any]]:
    """Return os_log entries, optionally filtered. Case-insensitive contains.

    `limit` counts *post-filter* matches — including the window filter — so
    the caller gets N matching logs inside the window rather than the first
    N matching logs that might all fall outside it.
    """
    if OS_LOG_SCHEMA not in toc_schemas:
        return []
    xml_bytes = xctrace.export_schema(trace_path, OS_LOG_SCHEMA, run=run)
    stream = xml_utils.RowStream(xml_bytes)
    needle = message_contains.lower() if message_contains else None

    out: list[dict[str, Any]] = []
    for row in stream:
        time_el = row.get("time")
        if time_el is None:
            continue
        time_ns = xml_utils.int_text(stream.resolve(time_el))
        if time_ns is None:
            continue
        if not xml_utils.in_window(time_ns, window_ns):
            continue

        sub = _str_of(row, stream, "subsystem")
        cat = _str_of(row, stream, "category")
        typ = _str_of(row, stream, "message-type")
        fmt = _str_of(row, stream, "format-string")
        msg = _str_of(row, stream, "message") or fmt

        if subsystem and (sub or "") != subsystem:
            continue
        if category and (cat or "") != category:
            continue
        if message_type and (typ or "") != message_type:
            continue
        if needle and needle not in (msg or "").lower() and needle not in (fmt or "").lower():
            continue

        process_el = row.get("process")
        process = (
            xml_utils.extract_process(process_el, stream).get("name")
            if process_el is not None else None
        )

        out.append({
            "time_ns": time_ns,
            "time_ms": round(time_ns / 1_000_000, 3),
            "type": typ,
            "subsystem": sub,
            "category": cat,
            "process": process,
            "message": msg,
            "format_string": fmt,
        })
        if limit is not None and len(out) >= limit:
            break

    out.sort(key=lambda e: e["time_ns"])
    return out


def list_signposts(
    trace_path: Path,
    toc_schemas: frozenset[str],
    name_contains: str | None = None,
    subsystem: str | None = None,
    category: str | None = None,
    window_ns: tuple[int, int] | None = None,
    run: int = 1,
) -> dict[str, list[dict[str, Any]]]:
    """Return signpost intervals (paired begin/end) plus single-point events.

    Shape: { "intervals": [...], "events": [...] }. Intervals have
    start_ms/end_ms/duration_ms; events have a single time_ms.

    Reads two complementary schemas:
      * `os-signpost-interval`: already-paired intervals (this is where
        user-emitted signposts like com.example.MyApp typically land).
      * `os-signpost`: raw begin/end/event rows; we pair begins with ends
        ourselves and fall back to point events for unpaired rows. Most
        Apple-framework signposts (CloudKit, AppKit, …) live here.

    Filters are AND-combined. `name_contains` is a case-insensitive substring
    match. `window_ns` keeps intervals that overlap the window (not strict
    containment) and point events whose timestamp falls inside it.
    """
    # The two signpost schemas overlap: every paired begin/end in `os-signpost`
    # also shows up as a row in `os-signpost-interval`. To avoid duplicates we
    # prefer the pre-paired schema for intervals and only mine `os-signpost`
    # for point events (and for begin/end pairing as a fallback when the
    # interval schema is missing — older traces).
    intervals: list[dict[str, Any]] = []
    events: list[dict[str, Any]] = []

    has_intervals = OS_SIGNPOST_INTERVAL_SCHEMA in toc_schemas
    if has_intervals:
        intervals.extend(_read_interval_schema(trace_path, run=run))

    if OS_SIGNPOST_SCHEMA in toc_schemas:
        more_intervals, more_events = _read_event_schema(trace_path, run=run)
        if not has_intervals:
            intervals.extend(more_intervals)
        events.extend(more_events)

    intervals.sort(key=lambda i: i["start_ns"])
    events.sort(key=lambda e: e["time_ns"])

    needle = name_contains.lower() if name_contains else None

    def _matches(entry: dict) -> bool:
        if subsystem and (entry.get("subsystem") or "") != subsystem:
            return False
        if category and (entry.get("category") or "") != category:
            return False
        if needle and needle not in (entry.get("name") or "").lower():
            return False
        return True

    if subsystem or category or needle:
        intervals = [i for i in intervals if _matches(i)]
        events = [e for e in events if _matches(e)]

    if window_ns is not None:
        s, e = window_ns
        intervals = [
            i for i in intervals
            if not (i["end_ns"] < s or i["start_ns"] > e)
        ]
        events = [ev for ev in events if s <= ev["time_ns"] <= e]

    return {"intervals": intervals, "events": events}


def _read_interval_schema(trace_path: Path, run: int = 1) -> list[dict[str, Any]]:
    """Read the os-signpost-interval schema (pre-paired intervals)."""
    xml_bytes = xctrace.export_schema(trace_path, OS_SIGNPOST_INTERVAL_SCHEMA, run=run)
    stream = xml_utils.RowStream(xml_bytes)

    out: list[dict[str, Any]] = []
    for row in stream:
        start_el = xml_utils.first_present(row, "start", "time")
        dur_el = row.get("duration")
        if start_el is None or dur_el is None:
            continue
        start_ns = xml_utils.int_text(stream.resolve(start_el))
        dur_ns = xml_utils.int_text(stream.resolve(dur_el))
        if start_ns is None or dur_ns is None:
            continue
        end_ns = start_ns + dur_ns

        name = _str_of(row, stream, "name")
        sub = _str_of(row, stream, "subsystem")
        cat = _str_of(row, stream, "category")
        signpost_id = _str_of(row, stream, "identifier") or _str_of(row, stream, "signpost-id")
        process_el = row.get("process")
        process = (
            xml_utils.extract_process(process_el, stream).get("name")
            if process_el is not None else None
        )

        out.append({
            "start_ns": start_ns,
            "end_ns": end_ns,
            "duration_ns": dur_ns,
            "start_ms": round(start_ns / 1_000_000, 3),
            "end_ms": round(end_ns / 1_000_000, 3),
            "duration_ms": round(dur_ns / 1_000_000, 3),
            "name": name,
            "subsystem": sub,
            "category": cat,
            "process": process,
            "signpost_id": signpost_id,
        })
    return out


def _read_event_schema(
    trace_path: Path,
    run: int = 1,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    """Read the os-signpost schema and pair begin/end rows into intervals."""
    xml_bytes = xctrace.export_schema(trace_path, OS_SIGNPOST_SCHEMA, run=run)
    stream = xml_utils.RowStream(xml_bytes)

    pending: dict[tuple, dict] = {}
    intervals: list[dict[str, Any]] = []
    events: list[dict[str, Any]] = []

    for row in stream:
        time_el = xml_utils.first_present(row, "time", "start")
        if time_el is None:
            continue
        time_ns = xml_utils.int_text(stream.resolve(time_el))
        if time_ns is None:
            continue

        name = _str_of(row, stream, "name")
        sub = _str_of(row, stream, "subsystem")
        cat = _str_of(row, stream, "category")
        event_type = _str_of(row, stream, "event-type") or _str_of(row, stream, "message-type")
        signpost_id = _str_of(row, stream, "signpost-id") or _str_of(row, stream, "identifier")
        process_el = row.get("process")
        process = (
            xml_utils.extract_process(process_el, stream).get("name")
            if process_el is not None else None
        )

        key = (process, sub, cat, name, signpost_id)
        etype = (event_type or "").lower()

        if etype in ("begin", "interval begin", "start"):
            pending[key] = {"start_ns": time_ns, "name": name,
                            "subsystem": sub, "category": cat,
                            "process": process, "signpost_id": signpost_id}
        elif etype in ("end", "interval end", "stop"):
            start = pending.pop(key, None)
            if start is not None:
                dur_ns = time_ns - start["start_ns"]
                intervals.append({
                    **start,
                    "end_ns": time_ns,
                    "duration_ns": dur_ns,
                    "start_ms": round(start["start_ns"] / 1_000_000, 3),
                    "end_ms": round(time_ns / 1_000_000, 3),
                    "duration_ms": round(dur_ns / 1_000_000, 3),
                })
            else:
                events.append(_point_event(time_ns, name, sub, cat,
                                            process, signpost_id, event_type))
        else:
            events.append(_point_event(time_ns, name, sub, cat,
                                        process, signpost_id, event_type))

    # Unclosed begins are surfaced as point events so nothing is silently dropped.
    for info in pending.values():
        events.append(_point_event(info["start_ns"], info["name"],
                                    info["subsystem"], info["category"],
                                    info["process"], info["signpost_id"],
                                    "Begin (unclosed)"))

    return intervals, events


def _point_event(time_ns, name, subsystem, category, process, signpost_id, event_type):
    return {
        "time_ns": time_ns,
        "time_ms": round(time_ns / 1_000_000, 3),
        "name": name,
        "subsystem": subsystem,
        "category": category,
        "process": process,
        "signpost_id": signpost_id,
        "event_type": event_type,
    }


def _str_of(row, stream, key):
    el = row.get(key)
    if el is None:
        return None
    resolved = stream.resolve(el)
    txt = xml_utils.str_text(resolved) or resolved.get("fmt")
    return txt
scripts/instruments_parser/hangs.py
"""Hangs lane parser (schema `potential-hangs`).

The schema lacks inline backtraces — stacks come from Time Profiler samples
that overlap each hang's window. Correlation is done later in correlate.py.
"""
from __future__ import annotations

from pathlib import Path
from typing import Any

from . import xctrace, xml_utils

PREFERRED_SCHEMAS = ("potential-hangs",)
FALLBACK_SCHEMAS = ("main-thread-hang", "hang", "hangs")


def analyze(
    trace_path: Path,
    toc_schemas: frozenset[str],
    top_n: int = 10,
    window: tuple[int, int] | None = None,
    run: int = 1,
) -> dict[str, Any]:
    schema = _pick_schema(toc_schemas)
    if schema is None:
        return {
            "lane": "hangs",
            "available": False,
            "notes": ["Hangs data not present in trace."],
        }

    xml_bytes = xctrace.export_schema(trace_path, schema, run=run)
    stream = xml_utils.RowStream(xml_bytes)

    hangs: list[dict] = []
    for row in stream:
        start_el = row.get("start")
        dur_el = row.get("duration")
        type_el = row.get("hang-type")
        thread_el = row.get("thread")
        if start_el is None or dur_el is None:
            continue
        start_ns = xml_utils.int_text(stream.resolve(start_el))
        duration_ns = xml_utils.int_text(stream.resolve(dur_el))
        if start_ns is None or duration_ns is None:
            continue
        if not xml_utils.event_overlaps_window(start_ns, start_ns + duration_ns, window):
            continue
        hang_type = xml_utils.str_text(stream.resolve(type_el)) if type_el is not None else None
        thread = xml_utils.extract_thread(thread_el, stream) if thread_el is not None else None

        hangs.append({
            "start_ns": start_ns,
            "duration_ns": duration_ns,
            "end_ns": start_ns + duration_ns,
            "duration_ms": round(duration_ns / 1_000_000, 2),
            "start_ms": round(start_ns / 1_000_000, 2),
            "hang_type": hang_type or "Hang",
            "thread": thread,
        })

    hangs.sort(key=lambda h: h["duration_ns"], reverse=True)

    total_ms = sum(h["duration_ms"] for h in hangs)
    worst = hangs[0] if hangs else None

    # Severity buckets per Apple docs (Microhang: 250ms–500ms, Hang: ≥500ms).
    # We bucket by raw duration so the agent can reason about it.
    buckets = {"lt_250ms": 0, "250ms_1s": 0, "gt_1s": 0}
    for h in hangs:
        if h["duration_ms"] < 250:
            buckets["lt_250ms"] += 1
        elif h["duration_ms"] < 1000:
            buckets["250ms_1s"] += 1
        else:
            buckets["gt_1s"] += 1

    top_offenders = [
        {
            "start_ms": h["start_ms"],
            "duration_ms": h["duration_ms"],
            "hang_type": h["hang_type"],
            "thread": (h["thread"] or {}).get("name", ""),
        }
        for h in hangs[:top_n]
    ]

    return {
        "lane": "hangs",
        "available": True,
        "schema_used": schema,
        "metrics": {
            "count": len(hangs),
            "total_duration_ms": round(total_ms, 2),
            "worst_duration_ms": worst["duration_ms"] if worst else 0,
            "severity_buckets": buckets,
        },
        "top_offenders": top_offenders,
        "notes": [],
        "_events": hangs,  # retained for correlation
    }


def _pick_schema(available: frozenset[str]) -> str | None:
    for s in PREFERRED_SCHEMAS + FALLBACK_SCHEMAS:
        if s in available:
            return s
    return None
scripts/instruments_parser/hitches.py
"""Animation hitches lane parser.

Xcode 26 schema `hitches` columns: start, duration (hitch time), process,
is-system, swap-id, label, display, narrative-description. The
narrative-description field carries Apple's own attribution (e.g.
"Potentially expensive app update(s)") which is the highest-signal column.
"""
from __future__ import annotations

from collections import Counter
from pathlib import Path
from typing import Any

from . import xctrace, xml_utils

CANDIDATE_SCHEMAS = ("hitches", "animation-hitch", "hitch")

START_KEYS = ("start", "time", "sample-time")
DURATION_KEYS = ("duration", "hitch-duration", "frame-duration")


def analyze(
    trace_path: Path,
    toc_schemas: frozenset[str],
    top_n: int = 10,
    window: tuple[int, int] | None = None,
    run: int = 1,
) -> dict[str, Any]:
    schema = _pick_schema(toc_schemas)
    if schema is None:
        return {
            "lane": "hitches",
            "available": False,
            "notes": ["Animation hitches not present in trace."],
        }

    xml_bytes = xctrace.export_schema(trace_path, schema, run=run)
    stream = xml_utils.RowStream(xml_bytes)

    events: list[dict] = []
    narrative_counts: Counter[str] = Counter()
    system_count = 0

    for row in stream:
        start_ns = _first_int(row, stream, START_KEYS)
        duration_ns = _first_int(row, stream, DURATION_KEYS)
        if start_ns is None or duration_ns is None:
            continue
        if not xml_utils.event_overlaps_window(start_ns, start_ns + duration_ns, window):
            continue

        process_el = row.get("process")
        process = (
            xml_utils.extract_process(process_el, stream)
            if process_el is not None else None
        )

        narrative_el = row.get("narrative-description")
        narrative = xml_utils.str_text(stream.resolve(narrative_el)) if narrative_el is not None else None
        if narrative:
            narrative_counts[narrative] += 1

        is_system_el = row.get("is-system")
        is_system = _bool_text(stream.resolve(is_system_el)) if is_system_el is not None else None
        if is_system:
            system_count += 1

        events.append({
            "start_ns": start_ns,
            "end_ns": start_ns + duration_ns,
            "duration_ns": duration_ns,
            "hitch_duration_ns": duration_ns,  # Xcode 26 `duration` == hitch time
            "frame_duration_ns": None,
            "hitch_duration_ms": round(duration_ns / 1_000_000, 2),
            "frame_duration_ms": None,
            "start_ms": round(start_ns / 1_000_000, 2),
            "process": (process or {}).get("name"),
            "narrative": narrative,
            "is_system": bool(is_system) if is_system is not None else None,
        })

    events.sort(key=lambda e: e["duration_ns"], reverse=True)

    total_hitch_ms = sum(e["hitch_duration_ms"] for e in events)
    worst = events[0] if events else None

    per_process: dict[str, int] = {}
    for e in events:
        key = e["process"] or "unknown"
        per_process[key] = per_process.get(key, 0) + 1

    top_offenders = [
        {
            "start_ms": e["start_ms"],
            "hitch_duration_ms": e["hitch_duration_ms"],
            "frame_duration_ms": e["frame_duration_ms"],
            "process": e["process"],
            "narrative": e["narrative"],
            "is_system": e["is_system"],
        }
        for e in events[:top_n]
    ]

    return {
        "lane": "hitches",
        "available": True,
        "schema_used": schema,
        "metrics": {
            "count": len(events),
            "total_hitch_ms": round(total_hitch_ms, 2),
            "worst_hitch_ms": worst["hitch_duration_ms"] if worst else 0,
            "per_process": per_process,
            "system_hitches": system_count,
            "app_hitches": len(events) - system_count,
            "narrative_breakdown": dict(narrative_counts.most_common()),
        },
        "top_offenders": top_offenders,
        "notes": [],
        "_events": events,
    }


def _pick_schema(available: frozenset[str]) -> str | None:
    for s in CANDIDATE_SCHEMAS:
        if s in available:
            return s
    return None


def _first_int(row, stream, keys):
    for key in keys:
        el = row.get(key)
        if el is None:
            continue
        val = xml_utils.int_text(stream.resolve(el))
        if val is not None:
            return val
    return None


def _bool_text(elem) -> bool | None:
    txt = xml_utils.str_text(elem)
    if txt is None:
        return None
    return txt.strip() in ("1", "true", "True", "YES", "Yes")
scripts/instruments_parser/summary.py
"""Markdown summary renderer for the combined trace analysis."""
from __future__ import annotations


def render(result: dict) -> str:
    lines: list[str] = []
    trace = result.get("trace", "?")
    header = result.get("xctrace_version") or ""
    template = result.get("template") or ""
    duration_s = result.get("duration_s")
    lines.append(f"# Instruments Trace Analysis")
    meta = [p for p in [f"Trace: `{trace}`", header, template] if p]
    lines.append("  •  ".join(meta))
    if duration_s is not None:
        lines.append(f"Recording duration: {duration_s:.2f}s")
    lines.append("")

    lanes_by_name = {lane["lane"]: lane for lane in result.get("lanes", [])}

    _render_time_profiler(lines, lanes_by_name.get("time-profiler"))
    _render_hangs(lines, lanes_by_name.get("hangs"))
    _render_hitches(lines, lanes_by_name.get("hitches"))
    _render_swiftui(lines, lanes_by_name.get("swiftui"))
    _render_causes(lines, lanes_by_name.get("swiftui-causes"))
    _render_correlations(lines, result.get("correlations", []))

    return "\n".join(lines).rstrip() + "\n"


def _skipped_block(title: str, lane: dict | None) -> list[str]:
    if lane is None:
        return [f"## {title} — skipped (lane module not run)", ""]
    notes = lane.get("notes") or []
    note_text = f" — {notes[0]}" if notes else ""
    return [f"## {title} — skipped{note_text}", ""]


def _render_time_profiler(lines: list[str], lane: dict | None) -> None:
    if not lane or not lane.get("available"):
        lines.extend(_skipped_block("Time Profiler", lane))
        return
    m = lane["metrics"]
    lines.append(
        f"## Time Profiler — {m['total_samples']:,} samples, "
        f"{m['total_weight_ms']:.0f}ms CPU time"
    )
    if m.get("processes"):
        lines.append(f"Processes: {', '.join(m['processes'])}")
    lines.append("")
    if lane["top_offenders"]:
        lines.append("Top offenders:")
        for i, o in enumerate(lane["top_offenders"], 1):
            lines.append(
                f"{i}. `{_truncate(o['symbol'], 90)}` — "
                f"{o['percent']:.1f}% ({o['weight_ms']:.0f}ms, "
                f"{o['samples']} samples, {_short_thread(o['thread'])})"
            )
    for note in lane.get("notes") or []:
        lines.append(f"> {note}")
    lines.append("")


def _render_hangs(lines: list[str], lane: dict | None) -> None:
    if not lane or not lane.get("available"):
        lines.extend(_skipped_block("Hangs", lane))
        return
    m = lane["metrics"]
    buckets = m["severity_buckets"]
    lines.append(
        f"## Hangs — {m['count']} hangs, {m['total_duration_ms']:.0f}ms total, "
        f"worst {m['worst_duration_ms']:.0f}ms"
    )
    lines.append(
        f"Severity: <250ms={buckets['lt_250ms']}, "
        f"250ms–1s={buckets['250ms_1s']}, >1s={buckets['gt_1s']}"
    )
    lines.append("")
    for i, h in enumerate(lane["top_offenders"], 1):
        lines.append(
            f"{i}. {h['duration_ms']:.0f}ms {h['hang_type']} at "
            f"{h['start_ms']:.2f}ms on {_short_thread(h['thread'])}"
        )
    lines.append("")


def _render_hitches(lines: list[str], lane: dict | None) -> None:
    if not lane or not lane.get("available"):
        lines.extend(_skipped_block("Animation Hitches", lane))
        return
    m = lane["metrics"]
    lines.append(
        f"## Animation Hitches — {m['count']} hitches, "
        f"{m['total_hitch_ms']:.0f}ms total, worst {m['worst_hitch_ms']:.0f}ms"
    )
    if m.get("per_process"):
        pp = ", ".join(f"{k}={v}" for k, v in m["per_process"].items())
        lines.append(f"By process: {pp}")
    lines.append("")
    if m.get("narrative_breakdown"):
        nb = ", ".join(f"{k}={v}" for k, v in m["narrative_breakdown"].items() if k)
        if nb:
            lines.append(f"Apple attribution: {nb}")
    if m.get("system_hitches") is not None:
        lines.append(
            f"System vs app: system={m['system_hitches']}, app={m['app_hitches']}"
        )
    lines.append("")
    for i, h in enumerate(lane["top_offenders"], 1):
        narrative = f" — {h['narrative']}" if h.get("narrative") else ""
        src = " [system]" if h.get("is_system") else ""
        proc = f" ({h['process']})" if h.get("process") else ""
        lines.append(
            f"{i}. {h['hitch_duration_ms']:.0f}ms at {h['start_ms']:.2f}ms"
            f"{proc}{src}{narrative}"
        )
    lines.append("")


def _render_swiftui(lines: list[str], lane: dict | None) -> None:
    if not lane or not lane.get("available"):
        lines.extend(_skipped_block("SwiftUI", lane))
        return
    m = lane["metrics"]
    lines.append(
        f"## SwiftUI — {m['total_events']:,} updates across "
        f"{m['unique_views']} views, {m['total_duration_ms']:.0f}ms total"
    )
    if m.get("severity_breakdown"):
        sb = ", ".join(f"{k}={v}" for k, v in m["severity_breakdown"].items())
        lines.append(f"Severity: {sb}")
    if m.get("update_type_breakdown"):
        ub = ", ".join(f"{k}={v}" for k, v in m["update_type_breakdown"].items())
        lines.append(f"Update types: {ub}")
    lines.append("")
    if lane["top_offenders"]:
        lines.append("Heaviest views (by total body time):")
        for i, v in enumerate(lane["top_offenders"], 1):
            lines.append(
                f"{i}. `{_truncate(v['view'], 80)}` — {v['total_ms']:.0f}ms total, "
                f"{v['count']} updates (avg {v['avg_ms']:.2f}ms)"
            )
    if lane.get("high_severity_events"):
        lines.append("")
        lines.append("High-severity updates:")
        for i, e in enumerate(lane["high_severity_events"][:5], 1):
            cat = f" [{e['category']}]" if e.get("category") else ""
            lines.append(
                f"{i}. `{_truncate(e['view'], 60)}` — "
                f"{e['severity']} ({e['duration_ms']:.2f}ms at {e['start_ms']:.2f}ms){cat}"
            )
    lines.append("")


def _render_causes(lines: list[str], lane: dict | None) -> None:
    if not lane or not lane.get("available"):
        lines.extend(_skipped_block("SwiftUI Cause Graph", lane))
        return
    m = lane["metrics"]
    lines.append(
        f"## SwiftUI Cause Graph — {m['total_edges']:,} edges, "
        f"{m['unique_sources']} sources → {m['unique_destinations']} destinations"
    )
    lines.append("")
    if lane.get("top_sources"):
        lines.append("Top sources (who's driving the most updates):")
        for i, s in enumerate(lane["top_sources"][:5], 1):
            lines.append(f"{i}. `{_truncate(s['source'], 80)}` — {s['edges']:,} edges")
            for d in s["top_destinations"][:3]:
                lines.append(
                    f"    → `{_truncate(d['destination'], 70)}` {d['edges']:,}"
                )
    if lane.get("top_destinations"):
        lines.append("")
        lines.append("Top destinations (who's being invalidated most):")
        for i, d in enumerate(lane["top_destinations"][:5], 1):
            lines.append(f"{i}. `{_truncate(d['destination'], 80)}` — {d['edges']:,} edges")
            for s in d["top_sources"][:3]:
                lines.append(
                    f"    ← `{_truncate(s['source'], 70)}` {s['edges']:,}"
                )
    lines.append("")


def _render_correlations(lines: list[str], correlations: list[dict]) -> None:
    if not correlations:
        return
    lines.append("## Correlations")
    lines.append("")
    for c in correlations:
        t = c["trigger"]
        head = (
            f"- **{t['lane']}** at {t['start_ms']:.2f}ms "
            f"({t['duration_ms']:.0f}ms)"
        )
        if t.get("hang_type"):
            head += f" — {t['hang_type']}"
        lines.append(head)

        tp = c.get("time_profiler_main_thread")
        if tp is not None:
            cov = tp["main_running_coverage_pct"]
            lines.append(
                f"  - Main thread: {tp['samples_on_main']} running samples "
                f"({cov:.0f}% coverage — "
                f"{'blocked' if cov < 25 else 'mostly running'})"
            )
            for s in tp["hot_symbols"][:3]:
                lines.append(
                    f"    · `{_truncate(s['symbol'], 80)}` "
                    f"{s['percent_of_main']:.0f}% ({s['samples']} samples)"
                )
            if not tp["hot_symbols"]:
                lines.append("    · no main-thread samples in window")

        sui = c.get("swiftui_overlapping_updates")
        if sui:
            for s in sui[:3]:
                lines.append(
                    f"  - SwiftUI: `{s['view']}` {s['duration_ms']:.2f}ms "
                    f"(at {s['start_ms']:.2f}ms)"
                )
    lines.append("")


def _short_thread(name: str) -> str:
    if not name:
        return ""
    if name.startswith("Main Thread") or name == "main":
        return "main"
    # "NowPlaying Gigs (0x251990d) (NowPlaying Gigs, pid: 28401)" -> "tid 0x251990d"
    tid_start = name.find("(0x")
    if tid_start != -1:
        start = tid_start + 1
        end = name.find(")", start)
        if end != -1:
            return f"tid {name[start:end]}"
    return name[:40]


def _truncate(s: str, n: int) -> str:
    if len(s) <= n:
        return s
    return s[: n - 1] + "…"
scripts/instruments_parser/swiftui.py
"""SwiftUI lane parser (Xcode 26+).

Primary schema is `swiftui-updates` with columns: start, duration, id,
update-type, allocations, description, category, view-hierarchy, module,
view-name, process, thread, root-causes, severity, cause-graph-node,
full-cause-graph-node.

We aggregate by view-name across all SwiftUI schemas (future-proofing against
schema renames) and break severity out separately so the agent can focus on
the high-severity rows.
"""
from __future__ import annotations

from collections import Counter, defaultdict
from pathlib import Path
from typing import Any

from . import xctrace, xml_utils

START_KEYS = ("start", "time", "sample-time", "timestamp")
DURATION_KEYS = ("duration", "body-duration", "update-duration")
VIEW_KEYS = ("view-name", "view", "view-type", "name", "type")
MODULE_KEYS = ("module",)
CATEGORY_KEYS = ("category",)
UPDATE_TYPE_KEYS = ("update-type",)
SEVERITY_KEYS = ("severity",)
DESCRIPTION_KEYS = ("description",)

HIGH_SEVERITIES = {"High", "Very High", "Severe", "Critical"}

# Ongoing / unterminated updates carry a sentinel duration (≈ UINT64_MAX-ish).
# Any duration longer than an hour is almost certainly that sentinel and would
# break aggregates + the correlation overlap check.
_SENTINEL_DURATION_NS = 60 * 60 * 1_000_000_000  # 1 hour


def analyze(
    trace_path: Path,
    toc_schemas: frozenset[str],
    top_n: int = 10,
    window: tuple[int, int] | None = None,
    run: int = 1,
) -> dict[str, Any]:
    schemas = sorted(
        s for s in toc_schemas
        if s.startswith("swiftui") and not s.endswith("-causes")
    )
    if not schemas:
        return {
            "lane": "swiftui",
            "available": False,
            "notes": ["SwiftUI lane not in trace (Xcode 26+ SwiftUI template required)."],
        }

    events: list[dict] = []
    per_view_total_ns: dict[str, int] = defaultdict(int)
    per_view_count: dict[str, int] = defaultdict(int)
    severity_counts: Counter[str] = Counter()
    update_type_counts: Counter[str] = Counter()
    category_counts: Counter[str] = Counter()

    for schema in schemas:
        xml_bytes = xctrace.export_schema(trace_path, schema, run=run)
        stream = xml_utils.RowStream(xml_bytes)
        for row in stream:
            start_ns = _first_int(row, stream, START_KEYS)
            dur_ns = _first_int(row, stream, DURATION_KEYS)
            if start_ns is None or dur_ns is None:
                continue
            if dur_ns < 0 or dur_ns > _SENTINEL_DURATION_NS:
                # Unterminated / ongoing update; skip so it doesn't poison
                # totals and the correlation overlap check.
                continue
            if not xml_utils.event_overlaps_window(start_ns, start_ns + dur_ns, window):
                continue

            view = _first_str(row, stream, VIEW_KEYS)
            module = _first_str(row, stream, MODULE_KEYS)
            category = _first_str(row, stream, CATEGORY_KEYS)
            update_type = _first_str(row, stream, UPDATE_TYPE_KEYS)
            severity = _first_str(row, stream, SEVERITY_KEYS)
            description = _first_str(row, stream, DESCRIPTION_KEYS)
            # Fall back through description → category → update-type so the
            # agent sees "EnvironmentWriter: RootEnvironment" instead of
            # "<unknown>" when SwiftUI doesn't record a view type.
            if not view:
                view = description or category or update_type or "<unknown>"

            per_view_total_ns[view] += dur_ns
            per_view_count[view] += 1
            if severity:
                severity_counts[severity] += 1
            if update_type:
                update_type_counts[update_type] += 1
            if category:
                category_counts[category] += 1

            events.append({
                "schema": schema,
                "start_ns": start_ns,
                "end_ns": start_ns + dur_ns,
                "duration_ns": dur_ns,
                "duration_ms": round(dur_ns / 1_000_000, 2),
                "start_ms": round(start_ns / 1_000_000, 2),
                "view": view,
                "module": module,
                "category": category,
                "update_type": update_type,
                "severity": severity,
                "description": description,
            })

    events.sort(key=lambda e: e["duration_ns"], reverse=True)

    top_by_total = sorted(
        per_view_total_ns.items(), key=lambda kv: kv[1], reverse=True
    )[:top_n]
    top_offenders = [
        {
            "view": view,
            "total_ms": round(total_ns / 1_000_000, 2),
            "count": per_view_count[view],
            "avg_ms": round(total_ns / per_view_count[view] / 1_000_000, 2),
        }
        for view, total_ns in top_by_total
    ]

    high_severity = [
        {
            "view": e["view"],
            "severity": e["severity"],
            "duration_ms": e["duration_ms"],
            "start_ms": e["start_ms"],
            "category": e["category"],
            "update_type": e["update_type"],
            "description": e["description"],
        }
        for e in events if e["severity"] in HIGH_SEVERITIES
    ][:top_n]

    longest = [
        {
            "view": e["view"],
            "duration_ms": e["duration_ms"],
            "start_ms": e["start_ms"],
            "category": e["category"],
            "update_type": e["update_type"],
            "severity": e["severity"],
        }
        for e in events[:top_n]
    ]

    return {
        "lane": "swiftui",
        "available": True,
        "schemas_used": schemas,
        "metrics": {
            "total_events": len(events),
            "unique_views": len(per_view_total_ns),
            "total_duration_ms": round(
                sum(per_view_total_ns.values()) / 1_000_000, 2
            ),
            "severity_breakdown": dict(severity_counts.most_common()),
            "update_type_breakdown": dict(update_type_counts.most_common()),
            "category_breakdown": dict(category_counts.most_common()),
        },
        "top_offenders": top_offenders,
        "longest_single_events": longest,
        "high_severity_events": high_severity,
        "notes": [],
        "_events": events,
    }


def _first_int(row, stream, keys):
    for key in keys:
        el = row.get(key)
        if el is None:
            continue
        val = xml_utils.int_text(stream.resolve(el))
        if val is not None:
            return val
    return None


def _first_str(row, stream, keys):
    for key in keys:
        el = row.get(key)
        if el is None:
            continue
        resolved = stream.resolve(el)
        txt = xml_utils.str_text(resolved) or resolved.get("fmt")
        if txt:
            return txt
    return None
scripts/instruments_parser/time_profiler.py
"""Time Profiler lane parser (schema `time-profile`).

Aggregates CPU samples by leaf symbol, keeps per-sample rows so that other
lanes can correlate by timestamp window.
"""
from __future__ import annotations

from collections import defaultdict
from pathlib import Path
from typing import Any

from . import xctrace, xml_utils

PREFERRED_SCHEMAS = ("time-profile",)
FALLBACK_SCHEMAS = ("time-sample",)  # no symbolication; used only if nothing else


def analyze(
    trace_path: Path,
    toc_schemas: frozenset[str],
    top_n: int = 10,
    window: tuple[int, int] | None = None,
    run: int = 1,
) -> dict[str, Any]:
    schema = _pick_schema(toc_schemas)
    if schema is None:
        return {
            "lane": "time-profiler",
            "available": False,
            "notes": ["Time Profiler data not present in trace."],
        }

    xml_bytes = xctrace.export_schema(trace_path, schema, run=run)
    stream = xml_utils.RowStream(xml_bytes)

    samples: list[dict] = []
    symbol_weight: dict[str, int] = defaultdict(int)
    symbol_samples: dict[str, int] = defaultdict(int)
    symbol_thread: dict[str, str] = {}
    processes: set[str] = set()
    total_weight = 0
    min_time: int | None = None
    max_time: int | None = None

    for row in stream:
        time_el = row.get("time")
        weight_el = row.get("weight")
        thread_el = row.get("thread")
        stack_el = row.get("stack")
        if stack_el is None or time_el is None or thread_el is None:
            continue

        sample_time_ns = xml_utils.int_text(stream.resolve(time_el))
        if not xml_utils.in_window(sample_time_ns, window):
            continue
        weight_ns = xml_utils.int_text(stream.resolve(weight_el)) or 0
        frames = xml_utils.extract_backtrace(stack_el, stream, max_frames=20)
        if not frames:
            continue

        thread = xml_utils.extract_thread(thread_el, stream)
        process_name = (thread.get("process") or {}).get("name")
        if process_name:
            processes.add(process_name)

        leaf = xml_utils.top_symbol(frames)
        symbol_weight[leaf] += weight_ns
        symbol_samples[leaf] += 1
        symbol_thread.setdefault(
            leaf, "main" if thread["is_main"] else thread.get("name", "")
        )
        total_weight += weight_ns

        if sample_time_ns is not None:
            min_time = sample_time_ns if min_time is None else min(min_time, sample_time_ns)
            max_time = sample_time_ns if max_time is None else max(max_time, sample_time_ns)

            samples.append({
                "time_ns": sample_time_ns,
                "weight_ns": weight_ns,
                "thread_name": thread["name"],
                "is_main": thread["is_main"],
                "process": process_name,
                "leaf_symbol": leaf,
                "frames": frames[:5],
            })

    samples.sort(key=lambda s: s["time_ns"])

    top = sorted(
        symbol_weight.items(), key=lambda kv: kv[1], reverse=True
    )[:top_n]
    top_offenders = [
        {
            "symbol": sym,
            "weight_ns": w,
            "weight_ms": round(w / 1_000_000, 2),
            "samples": symbol_samples[sym],
            "percent": round(100.0 * w / total_weight, 2) if total_weight else 0.0,
            "thread": symbol_thread.get(sym, ""),
        }
        for sym, w in top
    ]

    notes: list[str] = []
    if schema in FALLBACK_SCHEMAS:
        notes.append(
            f"Using fallback schema `{schema}`; backtraces may be unsymbolicated."
        )

    return {
        "lane": "time-profiler",
        "available": True,
        "schema_used": schema,
        "metrics": {
            "total_samples": len(samples),
            "total_weight_ns": total_weight,
            "total_weight_ms": round(total_weight / 1_000_000, 2),
            "window_start_ns": min_time,
            "window_end_ns": max_time,
            "processes": sorted(processes),
        },
        "top_offenders": top_offenders,
        "notes": notes,
        # Internal: retained for correlation. Stripped before JSON emission
        # if --slim is requested by the orchestrator.
        "_samples": samples,
    }


def _pick_schema(available: frozenset[str]) -> str | None:
    for s in PREFERRED_SCHEMAS + FALLBACK_SCHEMAS:
        if s in available:
            return s
    return None
scripts/instruments_parser/xctrace.py
"""Thin wrapper around the `xctrace` CLI."""
from __future__ import annotations

import subprocess
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class RunInfo:
    """Per-run metadata and schemas. Instruments traces can hold multiple runs."""
    number: int
    template_name: str | None
    duration_s: float | None
    start_date: str | None
    end_date: str | None
    schemas: frozenset[str]


@dataclass(frozen=True)
class TraceInfo:
    xctrace_version: str
    runs: tuple[RunInfo, ...]

    def get_run(self, number: int) -> RunInfo:
        for r in self.runs:
            if r.number == number:
                return r
        available = ", ".join(str(r.number) for r in self.runs)
        raise KeyError(f"run {number} not in trace (available: {available})")


def version() -> str:
    out = subprocess.run(
        ["xctrace", "version"], capture_output=True, text=True, check=True
    )
    return out.stdout.strip()


def toc(trace_path: Path) -> TraceInfo:
    """Export the trace's table of contents and return per-run metadata.

    The TOC is small (a few KB) so we load it fully rather than streaming.
    """
    xml_bytes = _run_export(trace_path, ["--toc"])
    root = ET.fromstring(xml_bytes)

    instruments = _find_text(root, ".//instruments-version") or ""

    runs: list[RunInfo] = []
    for run_el in root.iterfind("./run"):
        number_attr = run_el.get("number")
        if not number_attr:
            continue
        try:
            number = int(number_attr)
        except ValueError:
            continue
        if number <= 0:
            continue

        schemas: set[str] = set()
        for table in run_el.iterfind("./data/table"):
            schema = table.get("schema")
            if schema:
                schemas.add(schema)

        summary = run_el.find("./info/summary")
        if summary is not None:
            template = _find_text(summary, "./template-name")
            duration = _find_text(summary, "./duration")
            start = _find_text(summary, "./start-date")
            end = _find_text(summary, "./end-date")
        else:
            template = duration = start = end = None

        runs.append(RunInfo(
            number=number,
            template_name=template,
            duration_s=float(duration) if duration else None,
            start_date=start,
            end_date=end,
            schemas=frozenset(schemas),
        ))

    runs.sort(key=lambda r: r.number)
    return TraceInfo(
        xctrace_version=instruments,
        runs=tuple(runs),
    )


def export_schema(trace_path: Path, schema: str, run: int = 1) -> bytes:
    """Export one schema's data as XML bytes from the given run.

    Callers are expected to iterparse the result rather than build a full tree
    for large schemas (time-profile can be tens of MB).
    """
    xpath = f'/trace-toc/run[@number="{run}"]/data/table[@schema="{schema}"]'
    return _run_export(trace_path, ["--xpath", xpath])


def _run_export(trace_path: Path, extra_args: list[str]) -> bytes:
    cmd = ["xctrace", "export", "--input", str(trace_path), *extra_args]
    proc = subprocess.run(cmd, capture_output=True, check=False)
    if proc.returncode != 0:
        raise RuntimeError(
            f"xctrace export failed ({proc.returncode}): "
            f"{proc.stderr.decode(errors='replace').strip()}"
        )
    return proc.stdout


def _find_text(root: ET.Element, path: str) -> str | None:
    el = root.find(path)
    return el.text if el is not None and el.text else None
scripts/instruments_parser/xml_utils.py
"""Streaming XML helpers for xctrace export output.

Instruments XML deduplicates repeated values with `id`/`ref` attributes that
can span the whole document, so we stream rows with iterparse while keeping
a global id cache for later ref lookups.
"""
from __future__ import annotations

import xml.etree.ElementTree as ET
from collections.abc import Iterator
from dataclasses import dataclass


@dataclass(frozen=True)
class Column:
    mnemonic: str          # e.g. "time", "weight", "stack"
    engineering_type: str  # e.g. "sample-time", "weight", "tagged-backtrace"


class RowStream:
    """Iterate <row> elements of a single <table> schema export.

    Yields `dict[str, Element]` keyed by column mnemonic. Elements inside a
    yielded row are live ET elements (rooted in the id cache where applicable
    so ref resolution via `resolve()` remains valid after the row is yielded).
    """

    def __init__(self, xml_bytes: bytes):
        self._xml = xml_bytes
        self.columns: list[Column] = []
        self._id_cache: dict[str, ET.Element] = {}

    def resolve(self, element: ET.Element) -> ET.Element:
        """If the element is a ref, return the referenced element; else self."""
        ref = element.get("ref")
        if ref is None:
            return element
        target = self._id_cache.get(ref)
        if target is None:
            return element  # unresolved; return the ref element itself
        return target

    def __iter__(self) -> Iterator[dict[str, ET.Element]]:
        # iterparse fires `end` events once an element is fully parsed, so ids
        # are visible to descendants via the cache. We only need `end` events;
        # row bodies are reconstructed from the end element itself in _row_dict.
        #
        # NOTE: we intentionally don't call `elem.clear()` after yielding a row.
        # Instruments' XML is a single shared doc where any row can `ref` an
        # `id` defined earlier (threads, processes, stacks, metadata), and
        # clearing would break those later lookups. The tradeoff is peak RAM
        # ≈ document size. That's fine for typical traces up to a few hundred
        # MB; very large exports may need a smarter pass that first indexes
        # referenced ids and only retains those.
        schema_seen = False

        context = ET.iterparse(_bytes_to_file(self._xml), events=("end",))
        for _event, elem in context:
            eid = elem.get("id")
            if eid is not None:
                self._id_cache[eid] = elem

            if elem.tag == "schema" and not schema_seen:
                self.columns = _parse_columns(elem)
                schema_seen = True
                continue

            if elem.tag == "row":
                yield _row_dict(elem, self.columns)
                # Do not clear elem — children referenced via id may still be needed.


def _parse_columns(schema_el: ET.Element) -> list[Column]:
    cols: list[Column] = []
    for col in schema_el.findall("col"):
        mnemonic = (col.findtext("mnemonic") or "").strip()
        etype = (col.findtext("engineering-type") or "").strip()
        if mnemonic:
            cols.append(Column(mnemonic=mnemonic, engineering_type=etype))
    return cols


def _row_dict(row_el: ET.Element, cols: list[Column]) -> dict[str, ET.Element]:
    # Row children map positionally to columns. <sentinel/> marks a missing
    # optional value for that column.
    result: dict[str, ET.Element] = {}
    children = list(row_el)
    for idx, child in enumerate(children):
        if idx >= len(cols):
            break
        if child.tag == "sentinel":
            continue
        result[cols[idx].mnemonic] = child
    return result


def _bytes_to_file(data: bytes):
    import io
    return io.BytesIO(data)


# --- Extraction helpers ---------------------------------------------------

def int_text(elem: ET.Element | None) -> int | None:
    if elem is None or elem.text is None:
        return None
    try:
        return int(elem.text)
    except ValueError:
        return None


def str_text(elem: ET.Element | None) -> str | None:
    if elem is None or elem.text is None:
        return None
    return elem.text


def fmt_attr(elem: ET.Element | None) -> str | None:
    """Return the human-readable `fmt` attribute if present."""
    if elem is None:
        return None
    return elem.get("fmt")


def extract_thread(thread_el: ET.Element, stream: RowStream) -> dict:
    """Parse a <thread> element into name, tid, process dict.

    Handles ref-style threads by resolving through the stream's id cache.
    """
    resolved = stream.resolve(thread_el)
    name = resolved.get("fmt", "")
    tid_el = resolved.find("tid")
    process_el = resolved.find("process")
    process = extract_process(process_el, stream) if process_el is not None else None
    return {
        "name": name,
        "tid": int_text(tid_el),
        "process": process,
        "is_main": name.startswith("Main Thread") if name else False,
    }


def extract_process(process_el: ET.Element, stream: RowStream) -> dict:
    resolved = stream.resolve(process_el)
    name = resolved.get("fmt", "")
    pid_el = resolved.find("pid")
    return {
        "name": _clean_process_name(name),
        "pid": int_text(pid_el),
    }


def _clean_process_name(fmt: str) -> str:
    # "NowPlaying Gigs (28401)" -> "NowPlaying Gigs"
    if " (" in fmt and fmt.endswith(")"):
        return fmt.rsplit(" (", 1)[0]
    return fmt


def extract_backtrace(
    bt_el: ET.Element, stream: RowStream, max_frames: int = 20
) -> list[dict]:
    """Return a list of frame dicts from a <tagged-backtrace> or <backtrace>.

    Frames are ordered leaf-first (top of stack first), matching Instruments'
    display order.
    """
    resolved = stream.resolve(bt_el)
    inner = resolved.find("backtrace")
    if inner is None:
        inner = resolved
    frames: list[dict] = []
    for frame_el in inner.findall("frame"):
        f = stream.resolve(frame_el)
        frames.append({
            "name": f.get("name") or "",
            "addr": f.get("addr") or "",
        })
        if len(frames) >= max_frames:
            break
    return frames


def top_symbol(frames: list[dict]) -> str:
    """Pick the leaf symbol, falling back to addr if unsymbolicated."""
    if not frames:
        return "<empty-stack>"
    first = frames[0]
    return first.get("name") or first.get("addr") or "<unknown>"


def first_present(row: dict, *keys: str) -> ET.Element | None:
    """Return the first row column whose key exists.

    `row[key] or row[other_key]` is unsafe here: Element is falsy when it has
    no children (a common case for leaf <event-time>, <start-time>, etc.), so
    `or` short-circuits past valid leaf elements. This walks keys explicitly.
    """
    for key in keys:
        el = row.get(key)
        if el is not None:
            return el
    return None


def in_window(time_ns: int | None, window: tuple[int, int] | None) -> bool:
    """Return True if time_ns is inside [start, end] (inclusive), or window is None."""
    if window is None:
        return True
    if time_ns is None:
        return False
    start, end = window
    return start <= time_ns <= end


def event_overlaps_window(
    start_ns: int, end_ns: int, window: tuple[int, int] | None
) -> bool:
    """Return True if [start, end] overlaps [window.start, window.end]."""
    if window is None:
        return True
    w_start, w_end = window
    return not (end_ns < w_start or start_ns > w_end)
scripts/record_trace.py
#!/usr/bin/env python3
"""Record an Xcode Instruments .trace file via `xctrace record`.

Three modes:
  (default)          Start a recording. Stops on Ctrl+C, stop-file, or time limit.
  --list-devices     Enumerate connected devices + simulators as JSON.
  --list-templates   Enumerate available Instruments templates as JSON.

Attach vs launch vs all-processes is mutually exclusive and passed straight
through to xctrace. The default template is "SwiftUI" (matches the
SwiftUI template in Xcode 26+ — change with --template).

Manual stop options, most to least automated:
  * Send SIGINT (Ctrl+C) to this script — forwarded to xctrace, which
    finalises the trace before exiting.
  * Pass --stop-file PATH; when that file appears on disk, this script
    sends SIGINT to xctrace. Useful for `Bash run_in_background`
    workflows where there's no interactive terminal.
  * Pass --time-limit 30s / 5m / etc. — xctrace stops itself.
"""
from __future__ import annotations

import argparse
import json
import os
import re
import signal
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Record an Instruments .trace file.")
    list_mode = parser.add_mutually_exclusive_group()
    list_mode.add_argument("--list-devices", action="store_true",
                           help="List devices and simulators as JSON, then exit.")
    list_mode.add_argument("--list-templates", action="store_true",
                           help="List template names as JSON, then exit.")

    parser.add_argument("--template", default="SwiftUI",
                        help="Template name (default: SwiftUI).")
    parser.add_argument("--device", default=None,
                        help="Device name or UDID. Defaults to the host.")
    parser.add_argument("--output", type=Path, default=None,
                        help="Output .trace path. Defaults to ./<template>-<timestamp>.trace.")
    parser.add_argument("--time-limit", default=None,
                        help="Cap recording duration (e.g. 30s, 5m, 1h). Optional.")
    parser.add_argument("--stop-file", type=Path, default=None,
                        help="When this path appears on disk, stop the recording.")
    parser.add_argument("--env", action="append", default=[],
                        metavar="KEY=VALUE",
                        help="Env var for the launched process. Can repeat. Launch mode only.")
    parser.add_argument("--instrument", action="append", default=[],
                        help="Extra --instrument flag passthrough (can repeat).")
    parser.add_argument("--run-name", default=None)

    target = parser.add_mutually_exclusive_group()
    target.add_argument("--launch", metavar="APP",
                        help="Launch this .app path and record it.")
    target.add_argument("--attach", metavar="PID_OR_NAME",
                        help="Attach to a running process by pid or name.")
    target.add_argument("--all-processes", action="store_true",
                        help="Record every process (system-wide).")

    args = parser.parse_args(argv)

    if args.list_devices:
        _print_devices()
        return 0
    if args.list_templates:
        _print_templates()
        return 0

    if not (args.launch or args.attach or args.all_processes):
        print("error: need one of --launch, --attach, or --all-processes.",
              file=sys.stderr)
        return 2

    if args.env and not args.launch:
        # xctrace silently ignores --env outside launch mode; surfacing this
        # explicitly saves agents a confusing "why didn't my env var apply?".
        print("error: --env only applies to --launch; remove it or switch target mode.",
              file=sys.stderr)
        return 2

    output = args.output or Path.cwd() / _default_trace_name(args.template)
    if output.exists():
        print(f"error: output already exists: {output}", file=sys.stderr)
        return 2

    cmd = _build_xctrace_cmd(args, output)

    # Tell the user (and an agent reading stdout) what's happening + how to stop.
    print("[record] starting xctrace record", flush=True)
    print(f"[record]   template: {args.template}", flush=True)
    print(f"[record]   device:   {args.device or '(host)'}", flush=True)
    print(f"[record]   target:   {_describe_target(args)}", flush=True)
    print(f"[record]   output:   {output}", flush=True)
    stop_hints = ["Ctrl+C"]
    if args.stop_file:
        stop_hints.append(f"`touch {args.stop_file}`")
    if args.time_limit:
        stop_hints.append(f"after {args.time_limit}")
    print(f"[record] stop via: {', '.join(stop_hints)}", flush=True)
    print(f"[record] cmd: {' '.join(_shell_quote(c) for c in cmd)}", flush=True)

    # Start xctrace in its own process group so we can signal cleanly.
    proc = subprocess.Popen(cmd, start_new_session=True)

    try:
        _wait_with_stop(proc, args.stop_file)
    except KeyboardInterrupt:
        _forward_sigint(proc)

    # Give xctrace up to 60s to finalise after SIGINT — large traces take time.
    try:
        rc = proc.wait(timeout=60)
    except subprocess.TimeoutExpired:
        print("[record] xctrace did not exit within 60s after stop; killing.",
              file=sys.stderr)
        proc.kill()
        rc = proc.wait()

    if output.exists():
        print(f"[record] done. trace written: {output}", flush=True)
    else:
        print("[record] done but output file not found — did xctrace error?",
              file=sys.stderr)
        return rc or 1
    return rc


def _build_xctrace_cmd(args, output: Path) -> list[str]:
    cmd = ["xctrace", "record", "--template", args.template, "--output", str(output)]
    if args.device:
        cmd += ["--device", args.device]
    if args.time_limit:
        cmd += ["--time-limit", args.time_limit]
    if args.run_name:
        cmd += ["--run-name", args.run_name]
    for inst in args.instrument:
        cmd += ["--instrument", inst]
    for env in args.env:
        cmd += ["--env", env]
    # Target must come last — --launch consumes the remainder.
    if args.attach:
        cmd += ["--attach", args.attach]
    elif args.all_processes:
        cmd += ["--all-processes"]
    elif args.launch:
        cmd += ["--launch", "--", args.launch]
    return cmd


def _describe_target(args) -> str:
    if args.launch:
        return f"launch {args.launch}"
    if args.attach:
        return f"attach {args.attach}"
    if args.all_processes:
        return "all processes"
    return "(none)"


def _default_trace_name(template: str) -> str:
    safe = re.sub(r"[^A-Za-z0-9]+", "-", template).strip("-").lower() or "trace"
    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
    return f"{safe}-{ts}.trace"


def _wait_with_stop(proc: subprocess.Popen, stop_file: Path | None) -> None:
    """Poll until xctrace exits or stop_file appears; then send SIGINT."""
    while True:
        rc = proc.poll()
        if rc is not None:
            return
        if stop_file and stop_file.exists():
            print(f"[record] stop-file detected ({stop_file}); stopping xctrace.",
                  flush=True)
            _forward_sigint(proc)
            return
        time.sleep(0.5)


def _forward_sigint(proc: subprocess.Popen) -> None:
    try:
        # Signal the whole group so child instruments tools also get SIGINT.
        os.killpg(os.getpgid(proc.pid), signal.SIGINT)
    except ProcessLookupError:
        pass


def _print_devices() -> None:
    out = subprocess.run(
        ["xctrace", "list", "devices"], capture_output=True, text=True, check=True
    ).stdout
    devices: list[dict] = []
    section = None
    # Device lines end with "(UDID)"; real iOS devices also have "(OS version)"
    # before the UDID. The host (macOS) line has only "(UDID)".
    line_re = re.compile(r"^(.+?)(?:\s+\(([^()]+)\))?\s+\(([0-9A-Fa-f-]{20,})\)\s*$")
    for line in out.splitlines():
        stripped = line.strip()
        if not stripped:
            continue
        if stripped.startswith("==") and stripped.endswith("=="):
            section = stripped.strip("= ").strip().lower()
            continue
        m = line_re.match(stripped)
        if not m:
            continue
        name, os_ver, udid = m.group(1).strip(), m.group(2), m.group(3)
        devices.append({
            "kind": section or "unknown",
            "name": name,
            "os": os_ver,
            "udid": udid,
        })
    print(json.dumps({"devices": devices}, indent=2))


def _print_templates() -> None:
    out = subprocess.run(
        ["xctrace", "list", "templates"], capture_output=True, text=True, check=True
    ).stdout
    groups: dict[str, list[str]] = {}
    section = "unknown"
    for line in out.splitlines():
        stripped = line.strip()
        if not stripped:
            continue
        if stripped.startswith("==") and stripped.endswith("=="):
            section = stripped.strip("= ").strip().lower()
            groups.setdefault(section, [])
            continue
        groups.setdefault(section, []).append(stripped)
    # Flat convenience list + structured by section.
    flat = [name for items in groups.values() for name in items]
    print(json.dumps({"templates": flat, "by_section": groups}, indent=2))


def _shell_quote(s: str) -> str:
    if re.match(r"^[A-Za-z0-9_./:=@-]+$", s):
        return s
    return "'" + s.replace("'", "'\\''") + "'"


if __name__ == "__main__":
    sys.exit(main())