skills/ios-app-beauty/SKILL.md
Makes iOS apps feel premium, polished, and unmistakably native — SF Symbols mastery, SwiftUI spring animations, haptic feedback choreography, blur/vibrancy materials, Dynamic Type, color system design, and the specific spacings and radii Apple uses. This is about TASTE, not syntax. Activate on 'iOS design polish', 'SwiftUI animation', 'SF Symbols', 'haptic feedback', 'premium iOS feel', 'Apple HIG', 'native iOS look', 'Liquid Glass', 'iOS visual design', 'app polish'.
npx skillsauth add curiositech/windags-skills ios-app-beautyInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
The difference between a functional iOS app and a beautiful one is not more features. It is the weight of a toolbar icon matching the weight of the nav title. It is a spring animation that overshoots by exactly 4% before settling. It is a haptic tap that arrives at the precise millisecond a toggle lands. This skill is about the thousand tiny decisions that make an iOS app feel like it belongs on the platform.
Use for:
Do NOT use for:
Apple's design language has a set of unwritten rules that the best apps internalize:
Level 1: It works → Functional
Level 2: It looks right → Correct (follows HIG)
Level 3: It feels right → Native (motion, haptics, spacing)
Level 4: It feels inevitable → Premium (Things 3, Halide, Bear)
Most apps stop at Level 2. This skill gets you to Level 4.
Premium iOS apps share these qualities:
Reference apps to study:
SF Symbols come in 9 weights (ultralight through black). The weight of your icons MUST match the weight of adjacent text.
// WRONG: Default (regular) icon next to semibold text
Label("Settings", systemImage: "gear")
.font(.headline) // headline is semibold
// RIGHT: Match the icon weight to the text weight
Label {
Text("Settings").font(.headline)
} icon: {
Image(systemName: "gear")
.fontWeight(.semibold)
}
| Text Style | Font Weight | SF Symbol Weight |
|------------|-------------|-----------------|
| .largeTitle | Regular | .regular |
| .headline | Semibold | .semibold |
| .body | Regular | .regular |
| .caption | Regular | .regular |
| .title3 + .bold() | Bold | .bold |
SF Symbols support four rendering modes. Choosing the right one is a design decision, not a technical one.
// Monochrome — single color, most common, cleanest
Image(systemName: "heart.fill")
.symbolRenderingMode(.monochrome)
// Hierarchical — primary/secondary opacity, adds depth
Image(systemName: "square.and.arrow.up")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.blue)
// Palette — explicit colors per layer, bold statements
Image(systemName: "person.crop.circle.badge.plus")
.symbolRenderingMode(.palette)
.foregroundStyle(.white, .blue)
// Multicolor — Apple's predefined colors, use for system icons
Image(systemName: "externaldrive.badge.icloud")
.symbolRenderingMode(.multicolor)
When to use which:
Animated symbols are the single biggest upgrade in recent iOS design. They replace manual animations for common interactions.
// Bounce on tap — satisfying micro-interaction
Image(systemName: "heart.fill")
.symbolEffect(.bounce, value: isFavorited)
// Pulse while loading — alive without being distracting
Image(systemName: "arrow.down.circle")
.symbolEffect(.pulse, isActive: isDownloading)
// Variable color for progress — elegant loading indicator
Image(systemName: "wifi")
.symbolEffect(.variableColor.iterative, isActive: isConnecting)
// Replace with transition — icon morphing
Image(systemName: isMuted ? "speaker.slash" : "speaker.wave.2")
.contentTransition(.symbolEffect(.replace))
// Scale on appear — entrance animation
Image(systemName: "checkmark.circle.fill")
.symbolEffect(.appear, isActive: showCheck)
Never hardcode icon sizes. Use imageScale to match the text environment.
// Let the symbol scale with Dynamic Type
Image(systemName: "star.fill")
.imageScale(.medium) // .small, .medium, .large
// For custom sizing that still respects accessibility
Image(systemName: "star.fill")
.font(.system(size: 24))
Springs are the foundation of natural iOS motion. Every animation that moves an element should use a spring, not a linear or ease curve.
// Apple's built-in presets (use these first)
.animation(.spring, value: offset) // Default — good for most
.animation(.snappy, value: offset) // Quick settle — toggles, switches
.animation(.bouncy, value: offset) // Playful — celebrations, fun UI
.animation(.smooth, value: offset) // No bounce — serious/professional
// Custom spring — when presets aren't right
.animation(.spring(
response: 0.35, // Duration feel (seconds) — lower = faster
dampingFraction: 0.7, // 0 = infinite bounce, 1 = no bounce
blendDuration: 0 // Transition between animations
), value: offset)
| Context | Response | Damping | Feel | |---------|----------|---------|------| | Toggle/switch | 0.25 | 0.8 | Snappy, decisive | | Sheet presentation | 0.35 | 0.75 | Smooth, weighted | | Card expand | 0.4 | 0.7 | Satisfying settle | | Bounce celebration | 0.5 | 0.5 | Playful overshoot | | Drag release | 0.3 | 0.65 | Elastic snap-back | | Page transition | 0.45 | 0.85 | Cinematic, smooth | | Pull-to-refresh | 0.35 | 0.6 | Springy, physical | | Keyboard-driven nav | 0.2 | 0.9 | Instant, no play |
The secret to premium animation is not one animation — it is multiple animations happening at slightly different times with slightly different springs.
// Staggered list appearance
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
ItemRow(item: item)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 20)
.animation(
.spring(response: 0.4, dampingFraction: 0.8)
.delay(Double(index) * 0.05), // 50ms stagger
value: appeared
)
}
// Multi-property choreography — each property has its own timing
struct ExpandingCard: View {
@State private var isExpanded = false
var body: some View {
VStack {
// Scale arrives first (fast spring)
RoundedRectangle(cornerRadius: isExpanded ? 16 : 24)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isExpanded)
// Content fades in slightly after
if isExpanded {
Text("Details here")
.transition(.opacity.combined(with: .move(edge: .bottom)))
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: isExpanded)
}
}
}
}
For multi-step animations that cycle through states:
PhaseAnimator([false, true]) { phase in
Image(systemName: "bell.fill")
.rotationEffect(.degrees(phase ? 15 : -15))
.scaleEffect(phase ? 1.2 : 1.0)
} animation: { phase in
.spring(response: 0.2, dampingFraction: 0.3)
}
iOS has a specific haptic language. Using the wrong haptic is like using the wrong word — technically functional but feels wrong.
// IMPACT — physical collisions and weight
// Use when: elements land, snap into place, hit boundaries
UIImpactFeedbackGenerator(style: .light) // Subtle snap (toggle)
UIImpactFeedbackGenerator(style: .medium) // Solid tap (button press)
UIImpactFeedbackGenerator(style: .heavy) // Thud (drag drop, significant action)
UIImpactFeedbackGenerator(style: .soft) // Cushioned (elastic stretch)
UIImpactFeedbackGenerator(style: .rigid) // Sharp click (precise selection)
// SELECTION — moving through discrete values
// Use when: scrubbing a picker, scrolling through options, cursor on grid
UISelectionFeedbackGenerator() // Tick-tick-tick
// NOTIFICATION — semantic outcomes
// Use when: action completes with a result
UINotificationFeedbackGenerator().notificationOccurred(.success) // Completed
UINotificationFeedbackGenerator().notificationOccurred(.warning) // Caution
UINotificationFeedbackGenerator().notificationOccurred(.error) // Failed
// Modern declarative haptics
Button("Save") { save() }
.sensoryFeedback(.success, trigger: saveCompleted)
Toggle("Notifications", isOn: $notificationsOn)
.sensoryFeedback(.selection, trigger: notificationsOn)
// Impact on drag threshold
.sensoryFeedback(.impact(weight: .heavy), trigger: didCrossThreshold)
| Action | Haptic | Timing |
|--------|--------|--------|
| Button press | .impact(.light) | On touch down, not release |
| Toggle flip | .selection | At the flip point |
| Delete swipe | .impact(.medium) | When action commits |
| Pull-to-refresh release | .impact(.light) | When content snaps back |
| Long press menu | .impact(.heavy) | When menu appears |
| Picker scroll | .selection | Each value change |
| Success state | .notification(.success) | With the checkmark animation |
| Error state | .notification(.error) | With the shake animation |
| Drag snap to grid | .impact(.rigid) | At each grid line |
When built-in haptics aren't enough:
import CoreHaptics
func playSuccessPattern() {
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
let engine = try? CHHapticEngine()
try? engine?.start()
// Two-tap celebration pattern
let tap1 = CHHapticEvent(
eventType: .hapticTransient,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.6),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.4)
],
relativeTime: 0
)
let tap2 = CHHapticEvent(
eventType: .hapticTransient,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.8)
],
relativeTime: 0.1 // 100ms after first tap
)
let pattern = try? CHHapticPattern(events: [tap1, tap2], parameters: [])
let player = try? engine?.makePlayer(with: pattern!)
try? player?.start(atTime: 0)
}
Colors.xcassets/
├── Brand/
│ ├── Primary → Light: #1A1A2E Dark: #E8E8F0
│ ├── Secondary → Light: #4A90D9 Dark: #6BB5FF
│ └── Accent → Light: #FF6B35 Dark: #FF8F5E
├── Semantic/
│ ├── Background → Light: #FFFFFF Dark: #000000
│ ├── Surface → Light: #F5F5F7 Dark: #1C1C1E
│ ├── SurfaceElevated → Light: #FFFFFF Dark: #2C2C2E
│ ├── TextPrimary → Light: #1D1D1F Dark: #F5F5F7
│ ├── TextSecondary → Light: #86868B Dark: #98989D
│ └── Separator → Light: #D2D2D7 Dark: #38383A
└── State/
├── Success → Light: #34C759 Dark: #30D158
├── Warning → Light: #FF9F0A Dark: #FFD60A
└── Destructive → Light: #FF3B30 Dark: #FF453A
.primary, .secondary, .label, .systemBackground before creating custom colors..ultraThinMaterial instead of Color.black.opacity(0.5).// Apple's material system — automatically adapts to light/dark
.background(.ultraThinMaterial) // Barely there — navigation bars
.background(.thinMaterial) // Subtle — sidebars
.background(.regularMaterial) // Standard — sheets, popovers
.background(.thickMaterial) // Heavy — overlays on busy content
.background(.ultraThickMaterial) // Opaque — near-solid backgrounds
Apple uses an 8-point grid. Every spacing value should be a multiple of 4, with 8 as the standard unit.
// The spacing scale
let spacingXS: CGFloat = 4 // Tight: between icon and label
let spacingS: CGFloat = 8 // Compact: between related elements
let spacingM: CGFloat = 12 // Standard: list row internal padding
let spacingL: CGFloat = 16 // Comfortable: section padding, card insets
let spacingXL: CGFloat = 20 // Roomy: between sections
let spacingXXL: CGFloat = 24 // Generous: screen-edge margins (iPhone)
let spacingXXXL: CGFloat = 32 // Spacious: hero section spacing
// Apple's actual corner radii (measured from system apps)
let radiusSmall: CGFloat = 6 // Small chips, tags
let radiusStandard: CGFloat = 10 // Buttons, text fields
let radiusMedium: CGFloat = 12 // Cards, list rows
let radiusLarge: CGFloat = 16 // Sheets, larger cards
let radiusXLarge: CGFloat = 20 // Modals, group containers
let radiusScreen: CGFloat = 38.5 // iPhone 15 Pro screen radius (for continuous corners)
// CRITICAL: Use continuous corner style, not circular
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
// NOT: .cornerRadius(16) — this uses circular corners which look off on iOS
Apple uses continuous (superellipse) corners everywhere, not circular corners. This is the single most common mistake in third-party iOS apps.
// WRONG — circular corners (looks like Android)
.clipShape(RoundedRectangle(cornerRadius: 12))
// RIGHT — continuous corners (looks like Apple)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
Great iOS apps look BETTER with large text, not worse. This is a design skill, not just an accessibility checkbox.
// Scale with Dynamic Type — text and spacing
@ScaledMetric var iconSize: CGFloat = 24
@ScaledMetric var cardPadding: CGFloat = 16
// Test at ALL sizes: Settings > Accessibility > Larger Text
// Your layout should be beautiful at AX5 (the largest), not just default
Rules:
.body, .headline, .caption).@ScaledMetric.Apple's newest visual language emphasizes translucency and depth:
// Liquid Glass material — the new standard
.background(.liquidGlass)
// Key characteristics:
// - Translucent with refraction-like effects
// - Content behind affects appearance (not flat tint)
// - Rounded, fluid shapes (continuous corners everywhere)
// - Depth-aware: modals float above, toolbars recede
// Adapting to Liquid Glass:
// - Reduce opacity of decorative elements
// - Let content shine through navigation
// - Use system materials instead of custom blurs
// - Test with varied background content (photos, text, solid colors)
What it looks like: Standard CSS-style border-radius
Why it's wrong: Every Apple element uses continuous (superellipse) corners. Circular corners look foreign.
Fix: Always use RoundedRectangle(cornerRadius: r, style: .continuous)
What it looks like: Thin SF Symbols next to bold text, or vice versa
Why it's wrong: Creates visual tension; elements don't feel like they belong together
Fix: Match SF Symbol .fontWeight() to the text style weight
What it looks like: Elements that move at constant speed, stop abruptly
Why it's wrong: Nothing in the physical world moves linearly. It feels robotic.
Fix: Use .spring or .snappy for movement, .easeOut only for opacity
What it looks like: Haptic feedback on every scroll, every tap, every state change Why it's wrong: Overwhelms the sense; user becomes numb to meaningful haptics Fix: Haptics only at decision points, state changes, and physical metaphors
What it looks like: White text on #1A1A1A with the same assets as light mode Why it's wrong: Dark mode needs elevated surfaces, adjusted contrast ratios, and muted colors Fix: Design dark mode FIRST, then adapt to light. Use Asset Catalog with appearance variants.
What it looks like: Massive illustrations, long paragraphs explaining what the empty state means Why it's wrong: Empty states should invite action, not explain failure Fix: One icon, one line, one button: "No messages yet. Start a conversation."
Before considering any iOS screen "finished":
.continuous styleColor.black.opacity(0.5))ProgressView)tools
Building resilient distributed systems with circuit breakers, retries with full-jitter exponential backoff, retry budgets (per-request 3-attempt + per-client 10% ratio per Google SRE), deadline propagation, and the cascading-failure math (4 layers × 3 retries = 64x amplification). Grounded in Resilience4j, Microsoft Cloud Patterns, AWS Architecture Blog (Marc Brooker), and Google SRE Book.
testing
Designing HTTP cache headers that work correctly across browsers, CDNs, and shared proxies — `Cache-Control` directives per RFC 9111, `stale-while-revalidate` and `stale-if-error` per RFC 5861, the Vary header for varying responses, and surrogate keys for tag-based purging. Grounded in IETF RFCs and Cloudflare/Fastly docs.
development
Use when designing or fixing a Content Security Policy on a real site, choosing between nonce-based and hash-based CSP, adding strict-dynamic, debugging "Refused to execute inline script" errors, deploying CSP in report-only mode first, configuring report-to / report-uri, or auditing an existing policy for unsafe-inline / unsafe-eval / wildcards. Triggers: "CSP blocks legitimate inline script", strict-dynamic, nonce-{RANDOM}, sha256-{HASH}, object-src none, base-uri none, frame-ancestors, Trusted Types, X-Content-Security-Policy obsolete, report-only vs enforced. NOT for general HTTP security headers (HSTS, COOP/COEP), Trusted Types deep dive, CORS configuration, or building a WAF.
tools
Choosing and operating an HTTP API versioning strategy that doesn't break clients — Stripe's date-based pinned versions, the Deprecation/Sunset header pair (RFC 9745 + RFC 8594), URI vs header vs media-type approaches, and the version-transformer pattern. Grounded in Stripe's published architecture and IETF RFCs.