Brand and design · Specs

iOS handoff — SwiftUI mapping

SwiftUI component map, SF Symbols, localization, Dynamic Type, VoiceOver labels, hit targets.

Target: iOS 17+, Swift 6, SwiftUI. Tokens mirror tokens.css 1:1 (see tokens.md). Strings come from 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 ButtonStyles 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.