--- name: ios-ux description: iOS/SwiftUI UI & UX review and implementation guidelines for LibNovel. Enforces Apple HIG, iOS 17+ APIs, spring animations, haptics, accessibility, performance, and offline handling. Load this skill for any iOS view work. compatibility: opencode --- # iOS UI/UX Skill — LibNovel Load this skill whenever working on SwiftUI views in `ios/`. It defines design standards, review process for screenshots, and implementation rules. --- ## Screenshot Review Process When the user provides a screenshot of the app: 1. **Analyze first** — identify specific UI/UX issues across these categories: - Visual hierarchy and spacing - Typography (size, weight, contrast) - Color and material usage - Animation and interactivity gaps - Accessibility problems - Deprecated or non-native patterns 2. **Present a numbered list** of suggested improvements with brief rationale for each. 3. **Ask for confirmation** before writing any code: "Should I apply all of these, or only specific ones?" 4. Apply only what the user confirms. --- ## Design System ### Colors & Materials - **Accent**: `Color.amber` (project-defined). Use for active state, selection indicators, progress fills, and CTAs. - **Backgrounds**: Prefer `.regularMaterial`, `.ultraThinMaterial`, or `.thinMaterial` over hard-coded `Color.black.opacity(x)` or `Color(.systemBackground)`. - **Dark overlays** (e.g. full-screen players): Use `KFImage` blurred background + `Color.black.opacity(0.5–0.6)` overlay. Never use a flat solid black background. - **Semantic colors**: Use `.primary`, `.secondary`, `.tertiary` foreground styles. Avoid hard-coded `Color.white` except on dark material contexts (full-screen player). - **No hardcoded color literals** — use `Color+App.swift` extensions or system semantic colors. ### Typography - Use the SF Pro system font via `.font(.title)`, `.font(.body)`, etc. — never hardcode font names except for intentional stylistic accents (e.g. "Snell Roundhand" for voice watermark). - Apply `.fontWeight()` and `.fontDesign()` modifiers rather than custom font families. - Support Dynamic Type — never hardcode a fixed font size as the sole option without a `.minimumScaleFactor` or system font size modifier. - Hierarchy: title3.bold for primary labels, subheadline for secondary, caption/caption2 for metadata. ### Spacing & Layout - Minimum touch target: **44×44 pt**. Use `.frame(minWidth: 44, minHeight: 44)` or `.contentShape(Rectangle())` on small icons. - Prefer 16–20 pt horizontal padding on full-width containers; 12 pt for compact inner elements. - Use `VStack(spacing:)` and `HStack(spacing:)` explicitly — never rely on default spacing for production UI. - Corner radii: 12–14 pt for cards/chips, 10 pt for small badges, 20–24 pt for large cover art. --- ## Animation Rules ### Spring Animations (default for all interactive transitions) - Use `.spring(response:dampingFraction:)` for state-driven layout changes, selection feedback, and appear/disappear transitions. - Recommended defaults: - Interactive elements: `response: 0.3, dampingFraction: 0.7` - Entrance animations: `response: 0.45–0.5, dampingFraction: 0.7` - Quick snappy feedback: `response: 0.2, dampingFraction: 0.6` - Reserve `.easeInOut` only for non-interactive, ambient animations (e.g. opacity pulses, generating overlays). ### SF Symbol Transitions - Always use `contentTransition(.symbolEffect(.replace.downUp))` when a symbol name changes based on state (play/pause, checkmark/circle, etc.). - Use `.symbolEffect(.variableColor.cumulative)` for continuous animations (waveform, loading indicators). - Use `.symbolEffect(.bounce)` for one-shot entrance emphasis (e.g. completion checkmark appearing). - Use `.symbolEffect(.pulse)` for error/warning states that need attention. ### Repeating Animations - Use `phaseAnimator` for any looping animation that previously used manual `@State` + `withAnimation` chains. - Do not use `Timer` publishers for UI animation — prefer `phaseAnimator` or `TimelineView`. --- ## Haptic Feedback Add `UIImpactFeedbackGenerator` to every user-initiated interactive control: - `.light` — toggle switches, selection chips, secondary actions, slider drag start. - `.medium` — primary transport buttons (play/pause, chapter skip), significant confirmations. - `.heavy` — destructive actions (only if no confirmation dialog). Pattern: ```swift Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() // action } label: { ... } ``` Do **not** add haptics to: - Programmatic state changes not directly triggered by a tap. - Buttons inside `List` rows that already use swipe actions. - Scroll events. --- ## iOS 17+ API Usage Flag and replace any of the following deprecated patterns: | Deprecated | Replace with | |---|---| | `NavigationView` | `NavigationStack` | | `@StateObject` / `ObservableObject` (new types only) | `@Observable` macro | | `DispatchQueue.main.async` | `await MainActor.run` or `@MainActor` | | Manual `@State` animation chains for repeating loops | `phaseAnimator` | | `.animation(_:)` without `value:` | `.animation(_:value:)` | | `AnyView` wrapping for conditional content | `@ViewBuilder` + `Group` | Do **not** refactor existing `ObservableObject` types to `@Observable` unless explicitly asked — only apply `@Observable` to new types. --- ## Accessibility Every view must: - Support VoiceOver: add `.accessibilityLabel()` to icon-only buttons and image views. - Support Dynamic Type: test that text doesn't truncate at xxxLarge without a layout adjustment. - Meet contrast ratio: text on tinted backgrounds must be legible — avoid `.opacity(0.25)` or lower for any user-readable text. - Touch targets ≥ 44pt (see Spacing above). - Interactive controls must have `.accessibilityAddTraits(.isButton)` if not using `Button`. - Do not rely solely on color to convey state — pair color with icon or label. --- ## Performance - **Isolate high-frequency observers**: Any view that observes a `PlaybackProgress` (timer-tick updates) must be a separate sub-view that `@ObservedObject`-observes only the progress object — not the parent view. This prevents the entire parent from re-rendering every 0.5 seconds. - **Avoid `id()` overuse**: Only use `.id()` to force view recreation when necessary (e.g. background image on track change). Prefer `onChange(of:)` for side effects. - **Lazy containers**: Use `LazyVStack` / `LazyHStack` inside `ScrollView` for lists of 20+ items. `List` is inherently lazy and does not need this. - **Image loading**: Always use `KFImage` (Kingfisher) with `.placeholder` for remote images. Never use `AsyncImage` for cover art — it has no disk cache. - **Avoid `AnyView`**: It breaks structural identity and hurts diffing. Use `@ViewBuilder` or `Group { }` instead. --- ## Offline & Error States Every view that makes network calls must: 1. Wrap the body in a `VStack` with `OfflineBanner` at the top, gated on `networkMonitor.isConnected`. 2. Suppress network errors silently when offline via `ErrorAlertModifier` — do not show an alert when the device is offline. 3. Gate `.task` / `.onAppear` network calls: `guard networkMonitor.isConnected else { return }`. 4. Show a non-blocking inline empty state (not a full-screen error) for failed loads when online. --- ## Component Checklist (before submitting any view change) - [ ] All interactive elements ≥ 44pt touch target - [ ] SF Symbol state changes use `contentTransition(.symbolEffect(...))` - [ ] State-driven layout transitions use `.spring(response:dampingFraction:)` - [ ] Tappable controls have haptic feedback - [ ] No `NavigationView`, no `DispatchQueue.main.async`, no `.animation(_:)` without `value:` - [ ] High-frequency observers are isolated sub-views - [ ] Offline state handled with `OfflineBanner` + `NetworkMonitor` - [ ] VoiceOver labels on icon-only buttons - [ ] No hardcoded `Color.black` / `Color.white` / `Color(.systemBackground)` where a material applies