Brand and design · Stages
Design tokens — v1
Colour roles and limits, type styles, spacing, radii, elevation, motion and haptics, with the SwiftUI mapping.
Source of truth: 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.
displayandtimerare 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.