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.
  • 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.