---
title: "iOS handoff"
description: "SwiftUI component map, SF Symbols, localization, Dynamic Type, VoiceOver labels, hit targets."
source: "design/ios-handoff.md"
---

> Part of [Brand and design](/design/llms.txt). Every page: [llms.txt](/llms.txt).

# iOS handoff — SwiftUI mapping

Target: iOS 17+, Swift 6, SwiftUI. Tokens mirror [`tokens.css`](/design/tokens.css) 1:1 (see [`tokens.md`](/design/tokens/index.md)). Strings come from [`strings.json`](/design/strings.json) → `Localizable.xcstrings` (en source, zh-Hans). Fixtures in `fixtures/{en,zh-Hans}/` are API-shaped JSON for `#Preview` and snapshot tests.

## 1. Localization (ADR-001 D13)

- **Two independent languages.** The UI locale (`Locale.current`, overridable via `settings.ui_locale` → apply with `.environment(\.locale, …)` plus an in-app bundle override) is separate from the content language (`session.language` / `article.language`).
- Send `Accept-Language: <ui locale>` on every request. When the UI locale changes, invalidate all caches holding API strings and refetch.
- Content views (`ArticleView`, transcript quotes, assessment notes, one-action, exemplar) set `.environment(\.locale, Locale(identifier: article.language))` and `.accessibilityTextContentType(.prose)`. VoiceOver then speaks English quotes with an English voice inside a Chinese UI, and the other way round.
- Never branch on language for layout. Lengths come from `TextLength.label`, dates from `Date.FormatStyle` (`.month().day()`), numbers from `FormatStyle`, prices from `Product.displayPrice` / `priceFormatStyle`.
- Plurals: English strings with counts (`%lld sessions`, `%lld days`) become xcstrings plural variants when converting (one/other). Chinese has no plural forms. Listed in Known gaps for the converter.
- `**bold**` in strings.json is markdown; render with `Text(LocalizedStringKey(...))`.
- Fonts: Avenir Next has no CJK glyphs. Build `Font.shuke(_:)` with a cascade (Avenir Next → PingFang SC) via `UIFontDescriptor.cascadeList` so mixed text renders correctly. Reading text uses PingFang SC first for zh-Hans articles and SF Pro Text / New York (optional) for en articles. Line height 1.78 for CJK and 1.5 for Latin (`.lineSpacing` computed per language).
- Layout length rule: English runs ~1.6× Chinese. Every label wraps (`.fixedSize(horizontal: false, vertical: true)`), and buttons grow vertically. No `.lineLimit(1)` on content except tab labels.

## 2. Component inventory

| Component (HTML class) | SwiftUI | Custom / system | Notes |
|---|---|---|---|
| Screen scaffold (`.screen`, sticky `.navbar`) | `NavigationStack` + `.toolbar` (principal title, step count as `ToolbarItem`) | system | Nav bar background = `Color.bgCanvas`, no hairline (`.toolbarBackground(.visible)`) |
| Progress hair (`.progress-hair`) | `ProgressView(value:)` with custom style, 3 pt | custom style | Under the nav bar; animate `.smooth(0.4)` |
| Tab bar (`.tabbar`) | `TabView` with 4 tabs | system | SF Symbols below; tint `Color.textPrimary` |
| Primary CTA (`.btn--primary`) | `Button` + `PrimaryButtonStyle` | custom | 56 pt min height, `Radius.button` 16, 17 pt Bold, coral; pressed scale 0.98 |
| Ink / light / secondary / ghost / danger buttons | `ButtonStyle`s | custom | Same metrics |
| CTA dock (`.dock`) | `.safeAreaInset(edge: .bottom)` with a gradient material | custom | Content gets the inset automatically, so the last paragraph is never occluded |
| Card / ink card / sunken | `CardContainer(style:)` | custom | `RoundedRectangle(cornerRadius: 20, style: .continuous)` |
| Quest card | `QuestCard` | custom | Contains `ThreeStepPath`, `FocusChip`, CTA |
| 3-step path (`.path`) | `ThreeStepPath(steps:)` | custom | Accessible as one element: "Progress: 2 of 3 done" |
| Streak pill | `StreakPill(streak:)` | custom | `flame` / `flame.fill` |
| Level badge + XP bar | `LevelBadge`, `XPBar(from:to:)` | custom | XP bar animates with `.spring(response: 0.45, dampingFraction: 0.7)` |
| Chips (`.chip`) | `Toggle` with `ChipToggleStyle` / `Button` | custom | 36 pt visual, 44 pt hit (`.contentShape`) |
| Segmented (`.segmented`) | `Picker(.segmented)` | system | — |
| Option card (`.option`) | `OptionRow` with `.accessibilityAddTraits(.isSelected)` | custom | radio semantics |
| Day picker 3/5/7 | `Picker` custom style | custom | — |
| Time wheel | `DatePicker(.wheel, displayedComponents: .hourAndMinute)` | system | 5-min interval via `UIDatePicker.minuteInterval` |
| Record button + rings (`.rec`) | `RecordButton(state:)` | custom | Rings use `TimelineView(.animation)`; respects reduce motion |
| Timer (`.t-timer`) | `Text(duration, format: .time(pattern: .minuteSecond))` + `.monospacedDigit()` | system | `contentTransition(.numericText())` |
| Waveform (`.wave`) | `Canvas` driven by `AVAudioRecorder.averagePower` at 30 Hz | custom | Decorative to VoiceOver |
| Recording indicator | `RecordingIndicator` | custom | Plus the system orange dot |
| Sheets (`.sheet`) | `.sheet` + `.presentationDetents([.medium, .large])` + `.presentationCornerRadius(28)` | system | — |
| Alerts (`.alert`) | `.alert` / `.confirmationDialog` with `role: .destructive` | system | Delete confirms, leave recording |
| Toast | `ToastOverlay` | custom | `.accessibilityAddTraits(.updatesFrequently)`, posts an announcement |
| Banner | `InlineBanner(kind:)` | custom | — |
| Skeleton | `.redacted(reason: .placeholder)` + shimmer modifier | system + custom | Shimmer off with reduce motion |
| Score ring | `ScoreRing(score:band:)` (`Circle().trim`) | custom | Count-up via `.contentTransition(.numericText(value:))` |
| Dimension row | `DimensionRow` | custom | — |
| Evidence row + player | `EvidenceRow`, `ClipPlayer` (`AVPlayer` with `seek(to:)` + boundary observer at `end_ms`) | custom | Signed URL TTL 15 min, refetch on expiry |
| One-action card | `OneActionCard` | custom | — |
| Before/after block | `BeforeAfterBlock` | custom | — |
| Stat tiles | `StatTile` in `ViewThatFits { HStack; VStack }` | custom | Stacks at accessibility sizes |
| Trophy / level-up overlay | `CelebrationView` | custom | Skippable on tap |
| Growth chart | Swift Charts `LineMark` + `AreaMark` + `PointMark`; gaps by splitting series (`series:` per run) | system | Gap weeks: `RuleMark`-free hollow `PointMark` at the baseline; never interpolate |
| Skill grid | `LazyVGrid(columns: 2)` → 1 column at accessibility sizes | system | — |
| Paywall slider | `Slider(value:in:3...7, step: 2)` + tick buttons | system | `.sensoryFeedback(.selection, trigger: k)` |
| Paywall calendar | `Grid` 7 columns | custom | Hidden at accessibility sizes (timeline only) |
| Product card | `ProductCard(product:)` using StoreKit `Product` | custom | Or `SubscriptionStoreView` is **not** used: it can't show the honest projection layout |
| Settings | `Form` / `List(.insetGrouped)` | system | — |
| Sign in with Apple | `SignInWithAppleButton(.signIn)` `.signInWithAppleButtonStyle(.black / .white)` | system | — |

## 3. SF Symbols

| Use | Symbol |
|---|---|
| Back / close / chevron | `chevron.backward`, `xmark`, `chevron.forward` |
| Tabs | `house`, `books.vertical`, `chart.line.uptrend.xyaxis`, `person.crop.circle` |
| Mic / mic off | `mic.fill`, `mic.slash.fill` |
| Pause / play / stop | `pause.fill`, `play.fill`, `stop.fill` |
| Re-record / retry | `arrow.counterclockwise`, `arrow.clockwise` |
| Streak | `flame` / `flame.fill` |
| XP / star | `star.fill` |
| Trophy | `trophy.fill` |
| Sparkles (one action, quest) | `sparkles` |
| Target (focus, hint) | `scope` |
| Clock / time | `clock` |
| Exemplar audio | `speaker.wave.2.fill` |
| Settings | `slider.horizontal.3` |
| Lock | `lock.fill` |
| Offline | `wifi.slash` |
| Alert / info | `exclamationmark.circle`, `info.circle` |
| Bell | `bell` |
| Calendar | `calendar` |
| Delete | `trash` |
| Privacy | `checkmark.shield` |
| Compression | `scissors` |
| Efficiency | `bolt.fill` |
| Thumbs | `hand.thumbsup`, `hand.thumbsdown` |
| Upload / transcript | `icloud.and.arrow.up`, `text.alignleft` |
| Call interruption | `phone.fill` |
| Listening (unreliable) | `ear` |
| Languages | `character.bubble` |
| External / App Store | `arrow.up.forward.app` |
| Apple | system SIWA button (no symbol) |

## 4. Dynamic Type

- All text uses `Font.shuke(.style)` = `Font.custom(name, size:, relativeTo: style)`. The mapping table is in tokens.md §2.
- `display` and `timer` are capped with `.dynamicTypeSize(...DynamicTypeSize.accessibility1)`. Tab and nav titles follow system caps.
- At `isAccessibilitySize`: stat grids become vertical lists, before/after drops duration sub-labels, the paywall calendar is replaced by the timeline, day picker and skill grid go to one column, and the record ring shrinks to 176 pt.
- Verified in the gallery at 100 / 120 / 135 % (Type toggle) and `read--dynamic-type-xl`.

## 5. Safe areas and layout

- Reference device 393 × 852 pt (iPhone 15/16 Pro): top inset 59, bottom 34. Read real insets; never hard-code them.
- Horizontal gutter 20 pt. Bottom CTAs use `.safeAreaInset(edge: .bottom)` so scroll content ends above them.
- Sheets respect the bottom inset with 12 pt extra. The keyboard is not used in v1 (except the helpful-feedback comment, `.scrollDismissesKeyboard`).
- Landscape is not supported in v1 (`UISupportedInterfaceOrientations` portrait). iPad runs the iPhone layout (no split view in v1).

## 6. VoiceOver labels for icon-only controls

| Control | Label (key) | Trait / hint |
|---|---|---|
| Back | `common.back` | button |
| Close (completion) | `complete.close.a11y` | button |
| Record button | `record.button.start / pause / resume / stopped / denied` (changes with state) | button; hint "Double-tap to start recording" (system default) |
| Re-record | `record.rerecord` | button |
| Evidence timestamp chip | "Play from 00:48" (timestamp spoken as a duration) | button |
| Player play/pause | `feedback.exemplar.play.a11y` / `feedback.evidence.pause.a11y` / `processing.unreliable.play.a11y` | button, `.updatesFrequently` while playing |
| Thumbs up / down | `feedback.helpful.yes.a11y` / `.no.a11y` | toggle (selected trait) |
| Settings (profile) | `profile.settings.a11y` | button |
| Streak pill | `home.streak.a11y.done / todo` | static text |
| Level badge | `home.level.a11y` + XP progress value | static |
| XP bar | `home.level.progress.a11y`, value "240 of 350 XP" | `accessibilityValue` |
| 3-step path | `home.quest.path.a11y` | one combined element |
| Score ring | "Score 68, Good foundation" | combined |
| Topic tile | hidden (title row carries the meaning) | `.accessibilityHidden(true)` |
| Language tag | endonym ("English", "中文（简体）") | static |
| Paywall slider | `paywall.k.label`, value `paywall.k.a11y_value` | adjustable (increment/decrement by 2) |
| Calendar day | `paywall.calendar.day.a11y` (+ streak/billing) | static |
| Toggles | row title | system switch |
| Waveform, rays, decorative icons | hidden | — |

Reading order on feedback: banners → score + summary → dimensions → one action → evidence → key points → model answer → helpful. Article paragraphs are separate elements so rotor navigation by paragraph works.

## 7. Hit targets

Every interactive element is ≥ 44 × 44 pt. Chips (36 pt visual) and timestamp chips (22 pt visual) extend their hit area with `.contentShape(Rectangle().inset(by: -6..-11))` or padding. The record button is 96 pt. Icon buttons are 44 pt circles.

## 8. Reduced motion and other accessibility settings

- `@Environment(\.accessibilityReduceMotion)` (or `settings.reduce_motion_override`): count-ups show the final value; fills jump; rays hidden; trophy/level badge cross-fade 160 ms; rings stop breathing; sheets cross-fade; shimmer off. The live waveform stays (it's information).
- `accessibilityDifferentiateWithoutColor`: band and delta colours always come with text ("+11", band label). Recording state also uses the "Recording" label and the pause glyph.
- `colorSchemeContrast == .increased`: asset catalog High-Contrast variants (coral → `#C2410C`, tertiary text darker).
- `UIAccessibility.isVoiceOverRunning`: auto-stop sheet announces itself; processing posts announcements on stage changes; celebrations don't steal focus for more than one announcement.

## 9. Haptics

See tokens.md §7. Implement with `.sensoryFeedback(_:trigger:)` on the view that owns the state change; never from view models.
