# Platform Apple

> Use when building or reviewing iOS, iPadOS, or macOS UI in SwiftUI or UIKit — safe areas, Dynamic Type, materials, springs, VoiceOver.

- Skill: `yogvidwankhede/platform-apple` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add yogvidwankhede/platform-apple`
- Raw SKILL.md: https://api.skillmd.com/api/skills/yogvidwankhede/platform-apple/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: yogvidwankhede (https://skillmd.com/u/yogvidwankhede)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/yogvidwankhede/platform-apple

---


<!--
  Generated by Vishwakarma. Do not edit this file directly.
  Edit the source skill and run `vishwakarma sync` to regenerate.
-->

# Platform: Apple

Nearly every rule here follows from three mechanisms: the display is a fixed-density physical
object with obstructions in known places, the eye detects discontinuities in curvature and
velocity, and the system re-renders your interface at sizes and contrasts you did not choose.

The posture is to **express intent, not measurement**. `.font(.body)` is intent, `17pt` is
measurement; `UIColor.label` is intent, `#000000` is measurement. Measurements are correct
until the user turns on Larger Text or Increase Contrast, and then they are wrong in ways you
will never see because you did not turn those on.

---

## 1. Safe areas

The home-indicator bottom inset is 34pt; the status-bar inset is 20, 44, 47, 54 or 59pt by
generation. That list is the reason never to write any of it down — each value arrived with a
device that did not exist when the previous list looked complete.

The split that makes safe areas feel right: **the scroll view extends under the chrome while the
content's padding respects the inset**. A scroll view clipped at the boundary leaves a hard cut,
and the translucent bar above it has nothing to blur, so it renders flat grey. Use
`.safeAreaInset(edge:)` for persistent overlay chrome — it draws the bar *and* enlarges the
inset descendants respect, where `.overlay(alignment: .bottom)` looks identical and permanently
hides the final row. Name the region on every `.ignoresSafeArea`: `.container` is hardware and
chrome, `.keyboard` is the software keyboard, and the bare call ignores both, which is how the
field you are typing into ends up under the keyboard. Landscape horizontal insets are 44pt on
notched devices; add them to your padding rather than replacing it.

## 2. Touch targets

44×44pt minimum, roughly a fingertip's contact patch; below it the error rate climbs and the
errors cluster at screen edges and in the thumb's reach arc, which is where navigation lives.
Expand the target, not the artwork — but in SwiftUI a frame without
`.contentShape(Rectangle())` hit-tests against the glyph's alpha, so the padding is inert.
Adjacent hit rects need 8pt of clear space, and destructive actions need more than the layout
requires, since a mis-tap between Delete and Save is as likely as any other and far more costly.
On pointer-driven contexts the minimum relaxes; detect the input mechanism, not the platform.

## 3. Dynamic Type

Headline and Body are both 17pt and differ only in weight: hierarchy at small sizes is carried by
weight and colour, not size, so an invented 18pt step scales incoherently and reads as no level
at all. Use `.font(.body)`, `UIFontMetrics` for custom faces, and `@ScaledMetric` for
arbitrary numbers.

Content must survive to **AX5**, where Body renders around 53pt. The failure is never the text —
text scales. It is spacing frozen in points while text scales: a 44pt row clips its descenders, a
two-line label in a fixed 60pt card loses its second line. Let row height be whatever the text
needs, and at accessibility sizes turn horizontal label-and-value pairs into vertical stacks,
gated on `dynamicTypeSize.isAccessibilitySize` rather than on width. `.minimumScaleFactor`
reverses the user's explicit request for larger type and `.lineLimit(1)` loses content; reserve
both for numeric badges. The exception is fixed physical geometry: hit rects stay 44pt and
hairlines stay 1px, because fingers do not scale with a type setting.

## 4. Corners, materials, springs

Use `style: .continuous`. A circular arc holds constant curvature along the arc and zero along
the edge, so curvature jumps at the tangent point; the direction is continuous but the *rate of
turning* is not, and the eye reads a faint kink at all four corners. Nested radii must be
concentric — `childRadius = parentRadius − padding` — or the gap visibly thins toward them.

Materials are compositing recipes, not opacity values: a backdrop blur, a saturation boost to
about 180% (blurring averages neighbouring pixels toward the mean and desaturates), a vibrancy
pass, and a 1px light top edge that gives the surface thickness. Never stack two translucent
surfaces — the upper one samples an already-blurred backdrop and contrast collapses twice.

Parameterise springs as **duration and bounce**, never mass, stiffness and damping: the physical
triple is coupled, so raising stiffness to make it faster silently reduces the damping ratio and
adds overshoot. `bounce: 0` is critically damped and is the right default; a navigation push is
about 0.35s, and a value tracking a live gesture takes `.interactiveSpring`.

## 5. Semantic colour

Use `.label`, `.secondaryLabel`, `.systemBackground`, `.systemGroupedBackground`,
`.separator` and `.tintColor`. Each already resolves for light and dark, for Increase
Contrast, and for elevated versus base contexts. A literal `#8E8E93` matches `secondaryLabel`
in light mode, is illegible under Increase Contrast, and is wrong in dark mode — three bugs from
one hex value.

Dark mode on OLED uses a true black base, so elevation cannot be signalled by going darker than
nothing; layered surfaces use the *elevated* variants. Grouped backgrounds invert their
relationship with cells between appearances, so "grey page, white card" becomes a black card on a
black page. The semi-transparent `systemFill` family is separate from the background family and
is what filled controls use. Tint marks what is interactive; spend it decoratively and users
start tapping headers.

## 6. Navigation and sheets

Navigation bar 44pt, large title ~96pt collapsing on scroll, tab bar 49pt plus the 34pt inset —
83pt total, which is why a bar hard-coded at 49pt puts the home indicator on the labels. Tab bars
carry 2 to 5 tabs; more forces a "More" list and destroys the always-visible property that made
tabs worth using. The back button carries the previous screen's title, as a memory aid for where
it lands.

**The interactive pop gesture must keep working.** It is the only reliable back affordance on a
device with no hardware back button. A custom `leftBarButtonItem` without reassigning
`interactivePopGestureRecognizer.delegate`, or a horizontal scroll view against the left edge,
strands the user on a screen with no reachable exit; a custom edge gesture must begin outside the
leading 20pt. Sheets take `.presentationDetents`, a drag indicator only when resizable, and
`.presentationBackgroundInteraction` so a half-height sheet does not read as a modal.

## 7. Accessibility, haptics, and system states

Read `isReduceMotionEnabled`, `isReduceTransparencyEnabled` and
`isDarkerSystemColorsEnabled` at render time and observe their notifications, since users toggle
them mid-session. Reduced motion means gentler and fewer, not zero: replace slides and springs
with a ~200ms cross-fade and keep the colour changes that carry meaning. Annotate with
`.accessibilityLabel`, `.accessibilityHint`, `.accessibilityValue` and traits; `.isHeader`
populates the VoiceOver rotor, without which a rotor set to Headings finds nothing.

Call `prepare()` on a haptic generator when its trigger becomes likely, not when it fires: the
Taptic Engine takes tens of milliseconds to reach ready, and a cold trigger breaks the causal
binding that is the point. Never fire a haptic for something the user did not cause.

Key adaptation off size class, never `userInterfaceIdiom` or raw width: Slide Over gives a
compact-width window on a regular-width device, and Stage Manager lets the user resize at runtime.
Use `.redacted(reason: .placeholder)` for skeletons so geometry matches exactly,
`ContentUnavailableView` for empty states, and `.scrollTargetBehavior(.viewAligned)` for
carousels. The launch screen is the static shell, no logo and no text, and a destructive
confirmation labels its button with the verb, not "OK".

## Rules

### MUST NOT — Do not write status-bar or home-indicator heights as literals; take every inset from safeAreaInsets, the SwiftUI layout system, or env(safe-area-inset-*).

*Why:* The status-bar inset has been 20, 44, 47, 54 and 59pt across generations, and each of those values was introduced by a device that did not exist when the previous list looked complete. A literal is therefore correct only on the hardware it was measured on, and the failure appears on devices you cannot test because they have not shipped.

*Source:* [Apple Human Interface Guidelines, layout](https://developer.apple.com/design/human-interface-guidelines/layout)

Incorrect:

```swift
VStack { … }.padding(.top, 47).padding(.bottom, 34)
```

Correct:

```swift
VStack { … }  // safe area respected by the layout system
```

### MUST NOT — Do not layer one translucent material directly over another; give the region behind the upper surface an opaque backing.

*Why:* A material blurs and desaturates whatever it samples. Stacking two means the upper one samples an already-averaged backdrop, so contrast collapses toward the local mean twice over and the result is a muddy grey with nothing legible behind it. Under Reduce Transparency the same region must fall back to an opaque fill anyway, which is the behaviour you should have shipped.

Incorrect:

```swift
sheetBackground(.regularMaterial)  // over a .thinMaterial navigation bar
```

Correct:

```swift
sheetBackground(Color(.systemBackground))  // opaque over translucent chrome
```

### MUST — Attach persistent overlay chrome with .safeAreaInset(edge:) rather than .overlay(alignment:), and name the region on every .ignoresSafeArea call.

*Why:* safeAreaInset both draws the bar and enlarges the inset descendant scroll views respect, so the last row can still scroll clear of it; an overlay looks identical and permanently hides that row. Naming the region matters because .container is the hardware and chrome inset while .keyboard is the software keyboard, and a bare .ignoresSafeArea() ignores both, putting the field being typed into underneath the keyboard.

Incorrect:

```swift
ScrollView { … }.overlay(alignment: .bottom) { PlayerBar() }
```

Correct:

```swift
ScrollView { … }.safeAreaInset(edge: .bottom) { PlayerBar() }
```

### MUST — Give every interactive control a 44×44pt hit rect with 8pt of clear space to its neighbours, declaring .contentShape when the tappable area is larger than the drawn glyph.

*Why:* A fingertip contacts roughly 44pt of glass, and below that the error rate climbs sharply with the errors clustering at screen edges and in the thumb’s reach arc — exactly where navigation controls sit. Without .contentShape, SwiftUI hit-tests against the rendered glyph’s alpha, so a 44pt frame around a 16pt icon is 16pt of live target and 44pt of inert padding.

*Source:* [Apple Human Interface Guidelines, accessibility](https://developer.apple.com/design/human-interface-guidelines/accessibility)

*Exceptions:*
- Pointer-driven contexts such as macOS, or an iPad with a trackpad attached, where the input mechanism reports an exact pixel rather than a contact blob.

Incorrect:

```swift
Image(systemName: "xmark").onTapGesture { close() }
```

Correct:

```swift
Image(systemName: "xmark")
    .frame(width: 44, height: 44)
    .contentShape(Rectangle())
    .onTapGesture { close() }
```

### MUST — Express text with system text styles or UIFontMetrics and vertical spacing with @ScaledMetric, so the layout survives accessibility size AX5 without fixed row heights.

*Why:* At AX5 Body renders around 53pt, roughly 3.1 times default. Text itself scales fine; the failure is spacing frozen in points while text grows, so a 44pt row clips its descenders and a two-line label inside a fixed 60pt card loses its second line. Neither defect is visible at default size, which is the size the work is built at.

*Source:* [Apple Human Interface Guidelines, typography](https://developer.apple.com/design/human-interface-guidelines/typography)

Incorrect:

```swift
HStack(spacing: 12) { … }.frame(height: 44).font(.system(size: 17))
```

Correct:

```swift
@ScaledMetric(relativeTo: .body) private var rowSpacing: CGFloat = 12
HStack(spacing: rowSpacing) { … }.font(.body)
```

### MUST — Resolve text, background, separator, and fill colours through semantic system colours rather than literal hex values, using the systemFill family for filled controls.

*Why:* Each semantic colour already resolves for light and dark appearance, for Increase Contrast, and for elevated versus base contexts. A literal #8E8E93 matches secondaryLabel in light mode, is illegible under Increase Contrast, and is wrong in dark mode — three defects from one value. The fill family is separately non-interchangeable because fills are semi-transparent by design, so a control keeps working on both a white card and a grouped background.

*Source:* [Apple Human Interface Guidelines, color](https://developer.apple.com/design/human-interface-guidelines/color)

Incorrect:

```swift
Text("Subtitle").foregroundStyle(Color(hex: "#8E8E93"))
```

Correct:

```swift
Text("Subtitle").foregroundStyle(.secondary)
```

### MUST — Keep the interactive pop gesture working on every pushed screen, and start any custom edge gesture outside the leading 20pt.

*Why:* On a device with no hardware back button the edge swipe is the only reliable way back, and it is the one users on large phones depend on because the top-left button is outside thumb reach. Installing a custom leftBarButtonItem without reassigning the recogniser’s delegate, or putting a horizontal scroll view against the leading edge, silently disables it and strands the user on a screen with no reachable exit.

Incorrect:

```swift
navigationItem.leftBarButtonItem = customBack
```

Correct:

```swift
navigationItem.leftBarButtonItem = customBack
navigationController?.interactivePopGestureRecognizer?.delegate = self
```

### SHOULD — Draw rounded surfaces with style: .continuous or cornerCurve = .continuous rather than the default circular arc.

*Why:* A circular-arc corner holds constant curvature 1/r along the arc and zero along the straight edge, so curvature steps discontinuously at the tangent point even though the tangent direction is continuous. The visual system is sensitive to that second-order discontinuity, and it reads as a faint kink at all four corners — most visible at large radii and on light-on-dark surfaces.

Incorrect:

```swift
RoundedRectangle(cornerRadius: 20)
```

Correct:

```swift
RoundedRectangle(cornerRadius: 20, style: .continuous)
```

### SHOULD — Set a nested surface’s corner radius to the parent radius minus the padding between them, and square it off when that value would be negative.

*Why:* Two rounded rectangles are concentric only when their radii differ by exactly the gap. Give the child the parent’s radius and the gap is widest on the corner diagonal and narrowest at the edge midpoints, so the padding visibly thins toward the corners; give the child zero and a square inner corner sits inside a round outer one, reading as two unrelated objects.

Incorrect:

```swift
Card(cornerRadius: 20) { Inner(cornerRadius: 20).padding(12) }
```

Correct:

```swift
Card(cornerRadius: 20) { Inner(cornerRadius: 8).padding(12) }
```

### SHOULD — Specify springs as duration and bounce, or as damping ratio and response, rather than as mass, stiffness, and damping coefficient.

*Why:* The physical triple is coupled: raising stiffness to shorten the response also lowers the effective damping ratio, so an edit intended to make the motion faster silently adds overshoot and the two values get chased against each other. Damping ratio and response are independent and each maps to something perceivable — how much it overshoots, and how long it takes.

*Source:* [SwiftUI Animation, spring(duration:bounce:)](https://developer.apple.com/documentation/swiftui/animation)

Incorrect:

```swift
.animation(.interpolatingSpring(mass: 1, stiffness: 320, damping: 22), value: open)
```

Correct:

```swift
.animation(.spring(duration: 0.35, bounce: 0), value: open)
```

## Before reporting completion

Run these checks against your own output. Answer each question explicitly rather than
assuming the answer, because the point of the exercise is to notice what you did not
notice while building.

### Confirm geometry comes from the system and the layout survives the largest type size. (blocking)

- Does any device-generation status bar height (20, 44, 47, 54, 59) or the literal 34 appear as an inset in layout code?
- Does every scroll view with full-bleed content extend under the chrome, with insets applied as content padding rather than a frame inset, and does every .ignoresSafeArea name its region?
- Is every hit rect at least 44×44pt with 8pt of separation, and does every enlarged tappable area declare .contentShape?
- Render the primary screens at accessibility size AX5: does anything clip, truncate, or lose a line, and do horizontal label/value pairs become vertical stacks?
- Does any layout branch on userInterfaceIdiom or a raw width rather than on size class, and does it still work in a 320pt Slide Over window?

### Confirm corners, materials, colour, and spring parameters follow the platform mechanisms. (blocking)

- Does every rounded surface declare a continuous corner curve, and does each nested radius equal its parent’s radius minus the padding?
- Is any translucent material layered directly over another, and does every material have an opaque fallback under Reduce Transparency?
- Is every spring given as duration and bounce (or damping ratio and response) rather than mass, stiffness, and damping?
- List every colour literal in the diff. For each, which semantic colour or systemFill role should it be, and how does it resolve under Increase Contrast and in dark mode?
- Do carousels and paged content use .scrollTargetBehavior rather than a hand-rolled nearest-item snap?

### Confirm navigation, VoiceOver, haptics, and system-provided states behave correctly.

- Is the interactive pop gesture still enabled on every pushed screen, and does no custom gesture begin within the leading 20pt?
- Does the tab bar carry 2 to 5 tabs and reserve 83pt of height, and does each back button carry the previous screen’s title?
- Are isReduceMotionEnabled, isReduceTransparencyEnabled and isDarkerSystemColorsEnabled each read and acted on, with reduced motion becoming a ~200ms cross-fade rather than nothing?
- Does every non-text interactive element carry an accessibilityLabel, and does every heading carry the .isHeader trait so the rotor finds it?
- Does every haptic have prepare() called before its likely trigger, and can any haptic fire from an event the user did not initiate?
- Does the launch screen match the first real frame with no logo or text, and does every destructive confirmation label its button with the verb?

## Further reference

These are not loaded by default. Read one only when its question is the question you
currently have.

- `references/safe-areas-targets-and-dynamic-type.md` — How do I lay out against the notch, Dynamic Island, home indicator and keyboard, size hit rects, and build a layout that still works at the largest accessibility text size and in a Slide Over window?
- `references/corners-materials-springs-and-colour.md` — Why do Apple surfaces use continuous corners and layered materials, how should I parameterise a spring, which semantic colour family applies, and what makes scrolling and snapping feel like one physical system?
- `references/navigation-accessibility-and-system-integration.md` — What are the structural rules for navigation bars, tab bars, the pop gesture and sheets, and how do I wire VoiceOver, the accessibility toggles, haptics, launch screens, and the system state views correctly?

