---
title: "Design tokens"
description: "Colour roles and limits, type styles, spacing, radii, elevation, motion and haptics, with the SwiftUI mapping."
source: "design/tokens.md"
---

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

# Design tokens — v1

Source of truth: [`tokens.css`](/design/tokens.css). This file explains each token and how SwiftUI mirrors it **1:1**.

Naming rule: `--<group>-<role>[-<variant>]` → Swift `<Group>.<roleVariant>` in lowerCamelCase.

| CSS | SwiftUI |
|---|---|
| `--color-accent-primary` | `Color.accentPrimary` |
| `--color-bg-inverse-raised` | `Color.bgInverseRaised` |
| `--space-4` | `Spacing.s4` (16) |
| `--radius-card` | `Radius.card` |
| `--type-headline-*` | `Font.shuke(.headline)` |
| `--motion-duration-base` | `Motion.base` |
| `--elevation-2` | `.shukeElevation(.e2)` |

Brand-specific values (name, colours, fonts) live only here, so the TBD final brand is a token swap. Colours ship as an Xcode **asset catalog** with Any / Dark / High-Contrast appearances generated from this file. `Color.accentPrimary` resolves the right variant automatically.

---

## 1. Colour

### 1.1 Roles and their limits

These limits are the core of the visual language. Reviewers should reject any screen that breaks them.

| Role | Token | Light | Dark | Used for | Never used for |
|---|---|---|---|---|---|
| Canvas | `--color-bg-canvas` | `#FAF9F5` | `#121110` | App background, sticky nav bar, status-bar backdrop | — |
| Sunken | `--color-bg-sunken` | `#F5F2E9` | `#1C1B19` | Stat tiles, chips at rest, skeleton base, hint cards | Primary surfaces |
| Surface | `--color-bg-surface` | `#FFFFFF` | `#201F1C` | Cards, list groups, sheets | — |
| Ink (inverse) | `--color-bg-inverse` | `#171717` | `#2B2926` | **Today quest card, 这次只改一件事 card, before/after block, level badge, growth hero** | Generic containers |
| Ink raised | `--color-bg-inverse-raised` | `#262626` | `#36332F` | Chips and rows *inside* an ink card | — |
| **Coral** | `--color-accent-primary` | `#FF6030` | `#FF6030` | **Only the single primary action of a screen**, plus the live recording state (button, ring, waveform) | Bars, icons, body text, scores, decoration |
| Coral text | `--color-accent-primary-strong` | `#C4421A` | `#FF6A3D` | Coral **text** on light or dark surfaces (a link-like action word, a recording label), 4.5 : 1 or more | Backgrounds |
| Lime stroke | `--color-reward-deep` | `#80951D` | `#DFFC66` | Reward strokes and rays on light surfaces, 3 : 1 or more | Fills |
| **Lime** | `--color-reward` | `#DFFC66` | `#DFFC66` | **Only rewards**: XP badge and bar, level badge, achievements, round-2 score, the "done" step dot, completed-quest tag | Buttons, status, warnings |
| Violet | `--color-topic-violet` | `#6E4BFF` | `#8B6DFF` | Topic 心理, dimension 关键信息, round-1 chip, evidence playback highlight, focus dot | CTAs |
| Green | `--color-topic-green` | `#1E9E6A` | `#2DB37D` | Topic 知识, dimension 逻辑结构, round-2 chip, positive deltas, success | CTAs |
| Amber | `--color-topic-amber` | `#E09A1A` | `#F0AE38` | Topic 科技, dimension 表达效率, the streak pill's soft background | Text, strokes on light surfaces (2.4 : 1), errors |
| Amber text | `--color-status-warning` | `#A05F00` | `#F0AE38` | Warnings, the streak flame and count, the at-risk ring, the calendar's streak marker (white on it 5.1 : 1) | Errors |
| Danger | `--color-status-danger` | `#D33A2C` | `#FF6B5E` | Destructive actions, failed stage, errors | Scores (never red-shame a score) |

> Coral appears **at most once per screen** (the primary CTA). Exception: during capture the record button, its ring and waveform are coral and the dock CTA is also coral. Both mean "this is the live action".
> The prototype used coral for the streak pill and XP bar. v1 moves the streak to amber and the XP bar to lime-on-ink, so coral always means "the thing to tap".

Every pair above was measured with the WCAG 2.2 formula (2026-10-18). Body text needs 4.5 : 1, large or bold text
(≥ 18.66 px bold) and non-text UI 3 : 1. Coral and lime as fills are not text colours: coral **text** is
`--color-accent-primary-strong`, lime **strokes** are `--color-reward-deep`.

### 1.2 Text

| Token | Light | Dark | Contrast on canvas | Use |
|---|---|---|---|---|
| `--color-text-primary` | `#171717` | `#F5F2E9` | 16.9 : 1 | Body, titles |
| `--color-text-secondary` | `#5C5A55` | `#B9B4A9` | 6.6 : 1 | Supporting text |
| `--color-text-tertiary` | `#726E66` | `#8F8A80` | 4.8 : 1 (4.5 : 1 on sunken) | Captions and meta (≥ 12 pt only) |
| `--color-text-disabled` | `#A9A59C` | `#5E5A53` | n/a | Disabled labels only |
| `--color-text-on-inverse` | `#FAF9F5` | `#F5F2E9` | 16.4 : 1 on ink | Text on ink cards |
| `--color-text-on-inverse-secondary` | `#A8A49B` | `#ABA69B` | 7.2 : 1 on ink | Meta on ink cards |
| `--color-text-on-accent` | `#FFFFFF` | `#FFFFFF` | **3.0 : 1** on coral | CTA labels — see note |
| `--color-text-on-reward` | `#171717` | `#171717` | 14.8 : 1 on lime | Text on lime |

**CTA contrast note.** White on `#FF6030` is 3.0 : 1, in light and dark (dark used `#FF6A3D`, 2.85 : 1, which failed; it now uses the same coral). That passes WCAG AA only for large or bold text (≥ 18.66 px bold) and for non-text UI. CTA labels are therefore always **17 pt Bold** (`.btn`). When *Increase Contrast* is on, `--color-accent-primary` switches to `#C2410C` (white 5.2 : 1) through `:root[data-contrast="high"]` / the asset catalog's High-Contrast appearance. This is a deliberate brand trade-off and is listed in Known gaps.

### 1.3 Score bands (ADR-001 D3)

| Band | Range | Label | Token | Light |
|---|---|---|---|---|
| `retry` | < 50 | 需要再练 | `--color-band-retry` | `#B86E00` (amber, never red) |
| `basic` | 50–69 | 基础不错 | `--color-band-basic` | `#171717` |
| `clear` | 70–84 | 讲清楚了 | `--color-band-clear` | `#1E9E6A` |
| `excellent` | ≥ 85 | 出色 | `--color-band-excellent` | `#6E4BFF` |

Band colour applies to the score ring arc and the band label only. The number stays `text-primary`.

### 1.4 Dimension colours

`--color-dim-key-info` violet · `--color-dim-structure` green · `--color-dim-efficiency` amber · `--color-dim-fluency` ink.
The same colour is used for a dimension everywhere: feedback bars, growth bars, legend squares. Colour is never the only signal; the dimension name is always printed.

### 1.5 Topic colours

`Topic.color_token` (API enum `coral | violet | green | ink | amber`) maps to `--color-topic-<token>`. Topic tiles are square with a CJK glyph, so a coral topic tile can't be mistaken for a CTA.

---

## 2. Typography

Families:

- `--font-family-text`: Avenir Next → PingFang SC → system. UI text and Latin.
- `--font-family-reading`: PingFang SC first. Article body, quotes and the example sentence. CJK glyphs drive the rhythm here.
- `--font-family-numeric`: Avenir Next Heavy/Demi with tabular digits. Scores, timers, XP and counts.

- `--font-family-reading-latin`: Avenir Next. Used for English article bodies (`:lang(en)`) with a 28 pt leading (1.55) instead of the CJK 32 pt (1.78).

**Multilingual rules (ADR-001 D13).** UI text follows the UI locale. Content text follows the article's language (`lang=` / `.environment(\.locale)`), and the font stack and leading switch per language, not per UI locale. English copy runs ~1.6× longer than Chinese, so no component may assume a CJK line length. Titles wrap, buttons grow in height, chips scroll horizontally, and the gallery shows every screen in both languages to prove it.

Every size is multiplied by `--dt` (Dynamic Type factor). In SwiftUI, custom fonts scale with `Font.custom(_:size:relativeTo:)`.

| Token | Size / leading (pt) | Weight | iOS text style (`relativeTo:`) | Used for |
|---|---|---|---|---|
| `display` | 60 / 64 | Heavy | `.largeTitle` | Score count-up, paywall score, growth ability |
| `large-title` | 34 / 41 | Bold | `.largeTitle` | Tab screen titles, greeting |
| `title1` | 28 / 34 | Bold | `.title` | Screen headlines, article title |
| `title2` | 22 / 28 | Bold | `.title2` | Sheet titles, state titles |
| `title3` | 20 / 25 | Semibold | `.title3` | Section headers, feedback summary |
| `headline` | 17 / 22 | Semibold | `.headline` | Row titles, nav title |
| `body` | 17 / 24 | Regular | `.body` | Body copy |
| `reading` | 18 / 32 | Regular (PingFang) | `.body` | **Article paragraphs** (1.78 line height for CJK) |
| `callout` | 16 / 21 | Regular | `.callout` | Secondary rows |
| `subheadline` | 15 / 20 | Regular | `.subheadline` | Descriptions |
| `footnote` | 13 / 18 | Regular | `.footnote` | Meta, notes |
| `caption1` | 12 / 16 | Medium | `.caption` | Captions, legal |
| `caption2` | 11 / 13 | Semibold | `.caption2` | Tab labels, stat labels |
| `timer` | 44 / 48 | Heavy, monospaced digits | `.largeTitle` | Recording timer |
| `eyebrow` | 11 / 14, +0.08em, uppercase | Bold | `.caption2` | Section eyebrows |

Dynamic Type rules (tested at 100 %, 120 % and 135 % in the gallery):

- Text never truncates in content areas. It wraps. Row heights are `min-height`, never fixed.
- At accessibility sizes (≥ AX1) the three-stat grid becomes a vertical list. The before/after block keeps two columns but drops the duration sub-labels. The 14-day paywall calendar switches to the timeline list only. (iOS: `@Environment(\.dynamicTypeSize).isAccessibilitySize`.)
- The tab bar and nav bar titles follow iOS behaviour: they don't grow past XXXL, and the large-content viewer is used for tab items.
- `display` and `timer` are capped at `.accessibility1` (`.dynamicTypeSize(...DynamicTypeSize.accessibility1)`) so the record ring never overflows.

---

## 3. Spacing (4 pt grid)

| Token | pt | Swift |
|---|---|---|
| `--space-1` | 4 | `Spacing.s1` |
| `--space-2` | 8 | `Spacing.s2` |
| `--space-3` | 12 | `Spacing.s3` |
| `--space-4` | 16 | `Spacing.s4` |
| `--space-5` | 20 | `Spacing.s5` |
| `--space-6` | 24 | `Spacing.s6` |
| `--space-7` | 28 | `Spacing.s7` |
| `--space-8` | 32 | `Spacing.s8` |
| `--space-10` | 40 | `Spacing.s10` |
| `--space-12` | 48 | `Spacing.s12` |
| `--space-16` | 64 | `Spacing.s16` |
| `--space-gutter` | 20 | `Spacing.gutter`, horizontal screen margin |
| `--space-section` | 32 | `Spacing.section`, between sections |

Sizes: `--size-hit-min` 44 · `--size-cta-height` 56 · `--size-record-button` 96 · `--size-record-ring` 208 (compact 176 in round 2) · `--size-tabbar-height` 49 · `--size-navbar-height` 52 · safe areas 59 / 34 (iPhone 15/16 Pro reference; iOS reads the real insets).

## 4. Radii

| Token | pt | Use |
|---|---|---|
| `--radius-xs` | 8 | Tags, badges |
| `--radius-sm` | 12 | Evidence rows, inputs, topic tiles, banners |
| `--radius-md` | 16 | List groups, stat tiles, option cards, **buttons** |
| `--radius-lg` | 20 | Hero cards: quest, one-action, before/after |
| `--radius-xl` | 28 | Sheet top corners |
| `--radius-pill` | 999 | Chips, pills, XP bar |

SwiftUI: `RoundedRectangle(cornerRadius:, style: .continuous)` everywhere.

## 5. Elevation

| Token | Light | Dark | Use |
|---|---|---|---|
| `--elevation-0` | none | none | Flat |
| `--elevation-1` | 1 px hairline shadow + 3 px blur 5 % | 1 px border `#2E2C28` | Surface cards, list groups |
| `--elevation-2` | 24 px blur 10 % | border + 24 px blur 50 % | Ink cards, floating CTA |
| `--elevation-3` | 48 px blur 18 % | border + 48 px blur 60 % | Sheets, alerts, toasts |
| `--elevation-record` | 30 px coral glow 35 % | same | Recording button only |

Dark mode replaces shadows with 1 px borders, because shadows don't read on dark canvas.

## 6. Motion

| Token | Value | SwiftUI | Use |
|---|---|---|---|
| `--motion-duration-instant` | 100 ms | `.easeOut(duration: 0.1)` | Press scale 0.98 |
| `--motion-duration-fast` | 160 ms | `.snappy(duration: 0.16)` | Chip and option select, toggle |
| `--motion-duration-base` | 240 ms | `.smooth(duration: 0.24)` | Push/pop content, fades, toasts |
| `--motion-duration-slow` | 400 ms | `.smooth(duration: 0.4)` | Sheet, progress step, card expand, chart draw |
| `--motion-duration-celebrate` | 900 ms | `.spring(response: 0.45, dampingFraction: 0.7)` | Trophy pop, XP bar fill, level badge |
| `--motion-duration-countup` | 1200 ms | custom `TimelineView` / `contentTransition(.numericText())` | Score and XP count-up |
| `--motion-breath` | 1600 ms loop | `.easeInOut(duration: 0.8).repeatForever()` | Recording rings |
| `--motion-ease-standard` | `cubic-bezier(.2,0,0,1)` | `.timingCurve(0.2,0,0,1)` | Default |
| `--motion-ease-emphasized` | `cubic-bezier(.3,0,0,1)` | `.timingCurve(0.3,0,0,1)` | Entrances, fills |
| `--motion-ease-exit` | `cubic-bezier(.4,0,1,1)` | `.timingCurve(0.4,0,1,1)` | Dismissals |
| `--motion-ease-spring` | `cubic-bezier(.34,1.56,.64,1)` | `.spring(response: 0.45, dampingFraction: 0.7)` | Pops, toasts |

**Reduced motion** (`accessibilityReduceMotion`, or `UserSettings.reduce_motion_override = true`):

- Count-ups jump to the final value. VoiceOver reads the final value in either case.
- XP and progress fills are set without animation. Celebration rays are hidden. Trophy and level badge cross-fade (160 ms) instead of popping.
- Recording rings stop breathing. The live state is carried by colour, the "录音中" indicator and the running timer.
- The waveform keeps updating from audio levels, because it is information, not decoration. It renders without easing.
- Sheets use cross-fade instead of slide.

## 7. Haptics

The iOS 17 `sensoryFeedback` modifier is preferred. The UIKit generator is listed for parity.

| Moment | SwiftUI `.sensoryFeedback` | UIKit |
|---|---|---|
| Option / chip / segmented select, paywall slider detent (3/5/7), product select | `.selection` | `UISelectionFeedbackGenerator` |
| Primary CTA tap (start training, submit) | `.impact(weight: .medium)` | `UIImpactFeedbackGenerator(style: .medium)` |
| Recording **start** | `.impact(weight: .medium)` | `.medium` |
| Recording **pause / resume** | `.impact(weight: .light)` | `.light` |
| Recording **auto-stop** at max duration | `.warning` | `UINotificationFeedbackGenerator .warning` |
| Too-short / unreliable result | `.warning` | `.warning` |
| Assessment ready (processing → feedback) | `.success` | `.success` |
| Processing failed | `.error` | `.error` |
| Evidence row tap (seek playback) | `.selection` | selection |
| "做到了 ✓" banner appears | `.impact(flexibility: .soft, intensity: 0.7)` | `UIImpactFeedbackGenerator(style: .soft)` |
| Diagnostic ring completes | `.success` | `.success` |
| Completion trophy pop | `.success` | `.success` |
| XP bar fill ticks (every 10 XP, max 8 ticks) | `.selection` | selection |
| Streak +1 flame | `.impact(weight: .heavy)` … then nothing | `.rigid` |
| Level-up overlay | `.impact(weight: .heavy)` then `.success` after 120 ms | `.heavy` + `.success` |
| Achievement sheet | `.success` | `.success` |
| Purchase success / restore success | `.success` | `.success` |
| Purchase failed | `.error` | `.error` |
| Destructive confirm presented | `.warning` | `.warning` |

Haptics respect the system "System Haptics" switch automatically. Never fire more than one notification-type haptic within 300 ms.
