| Token | Value | Used for |
|---|---|---|
| durationPress | 120 ms | Press feedback. Scale 0.96 (full-size buttons 0.97), transform only. |
| durationChrome | 200 ms | Chrome, fades, small state morphs. easeOut. |
| durationSheet | 280 ms | Sheets, panes, feedback panels. Spring. |
| durationBloom | 280 ms | Tune and Quiz me wells. Soft overshoot, then settle. |
| durationFling | 380 ms | Card paging settle. Spring. |
| selectScale | 1.015 | A selected choice. |
| holdMs, flickMs, holdSlop | 350, 200, 16 | Gesture arbitration. Unchanged in round 2. |
| snackbarMs, toastMs | 3000, 1800 | Receipts. |
| springChrome | 0.35 / 0.78 | Response and damping, medium bounce. |
| Reduce Motion | 0.01 s linear | Every token collapses to a cut. |
| New token | Value | Used for |
|---|---|---|
| durationGrow | 520 ms | A word's state changes: the mark's fill, ring and underline, and the pill. Spring, bounce 0.18. |
| durationHop | 640 ms | Celebrate hop: anticipation, rise, land, settle. Once per Day kept. |
| durationBreath | 3200 ms | Idle breath, scale 1.000 to 1.015, from the bottom edge. Only in the welcome, empty states and Day kept. |
| durationCountRoll | 400 ms | The streak number rolls up once when a day is kept. numericText. |
| staggerStep | 60 ms | Delay between chips and rows in the Day kept entrance. |
| durationCoachPulse | 1200 ms | The tap ring on the first card. Twice, then it rests. |
| durationFlip | 420 ms | A flashcard turns over (the line sheet chips). Rotation about the vertical axis with perspective, spring bounce 0.12. Reduce Motion: a 120 ms cross-fade. |
// QoloaTheme.swift, beside animationBloom
static var animationGrow: Animation {
isReduceMotion
? .linear(duration: 0.01)
: .spring(duration: durationGrow, bounce: 0.18)
}struct WordMark<C: View>: View {
let state: MasteryState // .theNew .growing .familiar
@ViewBuilder var content: C
var body: some View {
content
.background { Fill().opacity(state == .theNew ? 1 : 0) }
.background { Ring().opacity(state == .growing ? 1 : 0) }
.overlay(alignment: .bottom) {
Underline()
.scaleEffect(y: state == .theNew ? 1
: state == .growing ? 0.67 : 0.33)
.opacity(state == .familiar ? 0.45 : 1)
}
.animation(QoloaTheme.animationGrow, value: state)
}
}// iOS 16 floor: delayed animations, as HearView does
.opacity(shown ? 1 : 0)
.offset(y: shown ? 0 : QoloaTheme.spaceSm)
.animation(
QoloaTheme.animationChrome
.delay(0.12 + 0.06 * Double(i)),
value: shown)
// Hop: KeyframeAnimator on 17+, chained springs on 16
if #available(iOS 17, *) {
HopKeyframes(trigger: kept)
} else {
HopSprings(trigger: kept)
}Haptics, existing vocabulary only (HapticsPort): Save word keeps its one existing haptic. A correct answer fires SUCCESS once, a miss fires ERROR once. Day kept fires SUCCESS once, on the frame the title lands. A word growing fires nothing by itself. Under Reduce Motion, outcome signals stay and impact ticks soften.
Constraint: the app target is iOS 16.0. KeyframeAnimator, PhaseAnimator, .sensoryFeedback and withAnimation(completionCriteria:) need iOS 17: gate them with #available and always ship the iOS 16 path.