Round 2 - motion tokens

Reuse the vocabulary that exists, add seven names

Already in ThemeTokens.kt and QoloaTheme.swift
TokenValueUsed for
durationPress120 msPress feedback. Scale 0.96 (full-size buttons 0.97), transform only.
durationChrome200 msChrome, fades, small state morphs. easeOut.
durationSheet280 msSheets, panes, feedback panels. Spring.
durationBloom280 msTune and Quiz me wells. Soft overshoot, then settle.
durationFling380 msCard paging settle. Spring.
selectScale1.015A selected choice.
holdMs, flickMs, holdSlop350, 200, 16Gesture arbitration. Unchanged in round 2.
snackbarMs, toastMs3000, 1800Receipts.
springChrome0.35 / 0.78Response and damping, medium bounce.
Reduce Motion0.01 s linearEvery token collapses to a cut.
Round 2 adds these, in ThemeTokens.kt first, then both platforms
New tokenValueUsed for
durationGrow520 msA word's state changes: the mark's fill, ring and underline, and the pill. Spring, bounce 0.18.
durationHop640 msCelebrate hop: anticipation, rise, land, settle. Once per Day kept.
durationBreath3200 msIdle breath, scale 1.000 to 1.015, from the bottom edge. Only in the welcome, empty states and Day kept.
durationCountRoll400 msThe streak number rolls up once when a day is kept. numericText.
staggerStep60 msDelay between chips and rows in the Day kept entrance.
durationCoachPulse1200 msThe tap ring on the first card. Twice, then it rests.
durationFlip420 msA 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.
SwiftUI, the token
// QoloaTheme.swift, beside animationBloom
static var animationGrow: Animation {
    isReduceMotion
        ? .linear(duration: 0.01)
        : .spring(duration: durationGrow, bounce: 0.18)
}
SwiftUI, the three marks
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)
  }
}
SwiftUI, iOS 16 floor
// 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.