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 viasettings.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 fromDate.FormatStyle(.month().day()), numbers fromFormatStyle, prices fromProduct.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 withText(LocalizedStringKey(...)).- Fonts: Avenir Next has no CJK glyphs. Build
Font.shuke(_:)with a cascade (Avenir Next → PingFang SC) viaUIFontDescriptor.cascadeListso 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 (.lineSpacingcomputed 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. displayandtimerare 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 (
UISupportedInterfaceOrientationsportrait). 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)(orsettings.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.