Skip to main content

damascene_core/anim/
mod.rs

1//! Animation primitives.
2//!
3//! Two motion models ship: spring physics (semi-implicit Euler) and
4//! cubic-bezier tweens. Springs are the default — they continue from
5//! current+velocity when retargeted mid-flight, which is what makes
6//! interrupted motion feel right (mouse-out-mid-fade eases back from
7//! where it is, not from rest). Tweens cover the explicit-duration
8//! cases where the curve matters more than the physics.
9//!
10//! ## Animatable values
11//!
12//! [`AnimValue`] holds the per-prop state the integrator works on.
13//! `Float` (1 channel) covers opacity / scale / translation; `Color`
14//! (4 channels) covers fills / strokes / text colors. The integrator
15//! treats each channel as an independent 1-D mass-spring-damper.
16//!
17//! ## Spring config
18//!
19//! Mass-spring-damper: `m·a = -k·x - c·v` where `x = current - target`,
20//! integrated semi-implicitly. `dt` is clamped to 64 ms so a stalled
21//! frame can't blow up the integrator. Settles when both displacement
22//! and velocity drop below epsilon for *all* channels.
23//!
24//! ## Headless determinism
25//!
26//! The bundle path calls [`Animation::settle`] on every in-flight
27//! animation before snapshotting, so SVG/PNG fixtures are byte-identical
28//! run-to-run regardless of how many frames were sampled.
29
30use std::time::Duration;
31// web_time::Instant works on wasm32 (std::time::Instant::now() panics there).
32use web_time::Instant;
33
34use crate::color::Oklab;
35use crate::tree::Color;
36
37pub mod tick;
38
39/// A value the animator can interpolate. Each variant fans out to a
40/// fixed number of f32 channels that the integrator steps independently.
41#[derive(Clone, Copy, Debug, PartialEq)]
42pub enum AnimValue {
43    Float(f32),
44    Color(Color),
45}
46
47impl AnimValue {
48    /// Per-variant `(displacement, velocity)` settle thresholds for the
49    /// spring integrator. Oklab-channeled colors live in a tighter
50    /// numeric range than pixel-offset floats, so they get tighter
51    /// epsilons.
52    pub fn settle_thresholds(self) -> (f32, f32) {
53        match self {
54            AnimValue::Color(_) => (SPRING_EPSILON_DISP_COLOR, SPRING_EPSILON_VEL_COLOR),
55            AnimValue::Float(_) => (SPRING_EPSILON_DISP_FLOAT, SPRING_EPSILON_VEL_FLOAT),
56        }
57    }
58
59    /// Decompose into spring-integrable f32 channels. Colors decompose
60    /// to [Oklab L, a, b, alpha] so spring physics produces perceptually
61    /// uniform mid-flight values — no muddy gray midpoint on
62    /// complementary lerps.
63    pub fn channels(self) -> AnimChannels {
64        match self {
65            AnimValue::Float(v) => AnimChannels {
66                n: 1,
67                v: [v, 0.0, 0.0, 0.0],
68            },
69            AnimValue::Color(c) => {
70                let lab = c.to_oklab();
71                AnimChannels {
72                    n: 4,
73                    v: [lab.l, lab.a, lab.b, lab.alpha],
74                }
75            }
76        }
77    }
78
79    /// Reconstruct an `AnimValue` of the same variant from sampled
80    /// channels. The token name is dropped — an in-flight interpolated
81    /// rgba doesn't equal any palette token's rgb, so carrying a name
82    /// on it would mislead palette resolution. When the animation
83    /// settles, `step_spring` / `step_tween` assign
84    /// `self.current = self.target` directly, restoring the target's
85    /// token on the final value. Channel space (and the target's
86    /// [`crate::color::ColorSpace`]) is recovered from the previous-frame
87    /// value (`self`) so spring overshoot stays in the space the author
88    /// authored in.
89    pub fn from_channels(self, ch: AnimChannels) -> AnimValue {
90        match self {
91            AnimValue::Float(_) => AnimValue::Float(ch.v[0]),
92            AnimValue::Color(prev) => {
93                let lab = Oklab {
94                    l: ch.v[0],
95                    a: ch.v[1],
96                    b: ch.v[2],
97                    alpha: ch.v[3],
98                };
99                AnimValue::Color(lab.to_color(prev.space))
100            }
101        }
102    }
103}
104
105#[derive(Clone, Copy, Debug)]
106pub struct AnimChannels {
107    pub n: usize,
108    pub v: [f32; 4],
109}
110
111impl AnimChannels {
112    pub fn zero(n: usize) -> Self {
113        Self { n, v: [0.0; 4] }
114    }
115}
116
117/// Spring physics configuration: mass-spring-damper.
118///
119/// The four preset constants are calibrated to feel competitive with
120/// modern native motion (UIKit defaults, Material 3 motion). Authors
121/// pick a preset; ad-hoc tuning is intentionally not exposed to keep
122/// the surface area small.
123#[derive(Clone, Copy, Debug)]
124pub struct SpringConfig {
125    pub mass: f32,
126    pub stiffness: f32,
127    pub damping: f32,
128}
129
130impl SpringConfig {
131    /// High stiffness, near-critical damping. ~150 ms settle, no
132    /// overshoot. Use for hover / focus where overshoot reads as jitter.
133    pub const QUICK: Self = Self {
134        mass: 1.0,
135        stiffness: 380.0,
136        damping: 30.0,
137    };
138    /// Balanced. ~250 ms settle, mild overshoot. Default state changes.
139    pub const STANDARD: Self = Self {
140        mass: 1.0,
141        stiffness: 200.0,
142        damping: 22.0,
143    };
144    /// Visible overshoot. Press-release rebound, playful interactions.
145    pub const BOUNCY: Self = Self {
146        mass: 1.0,
147        stiffness: 240.0,
148        damping: 14.0,
149    };
150    /// Soft, large displacements. Modal appearance, panel transitions.
151    pub const GENTLE: Self = Self {
152        mass: 1.0,
153        stiffness: 80.0,
154        damping: 18.0,
155    };
156}
157
158/// Cubic-bezier tween: P0=(0,0), P3=(1,1), with two control points.
159#[derive(Clone, Copy, Debug)]
160pub struct TweenConfig {
161    pub duration: Duration,
162    pub p1: (f32, f32),
163    pub p2: (f32, f32),
164}
165
166impl TweenConfig {
167    /// 100 ms ease-out. For micro-interactions where physics is overkill.
168    pub const EASE_QUICK: Self = Self {
169        duration: Duration::from_millis(100),
170        p1: (0.0, 0.0),
171        p2: (0.2, 1.0),
172    };
173    /// 200 ms ease-in-out. Symmetric default tween.
174    pub const EASE_STANDARD: Self = Self {
175        duration: Duration::from_millis(200),
176        p1: (0.4, 0.0),
177        p2: (0.2, 1.0),
178    };
179    /// 350 ms slow-out, fast-end. For larger displacements where the
180    /// final settle should feel decisive.
181    pub const EASE_EMPHASIZED: Self = Self {
182        duration: Duration::from_millis(350),
183        p1: (0.05, 0.7),
184        p2: (0.1, 1.0),
185    };
186}
187
188/// Choice of motion model for an animated property. Springs feel
189/// physical (continue from current+velocity on retarget); tweens feel
190/// curated (fixed curve, fixed duration).
191#[derive(Clone, Copy, Debug)]
192pub enum Timing {
193    Spring(SpringConfig),
194    Tween(TweenConfig),
195}
196
197impl Timing {
198    pub const SPRING_QUICK: Self = Timing::Spring(SpringConfig::QUICK);
199    pub const SPRING_STANDARD: Self = Timing::Spring(SpringConfig::STANDARD);
200    pub const SPRING_BOUNCY: Self = Timing::Spring(SpringConfig::BOUNCY);
201    pub const SPRING_GENTLE: Self = Timing::Spring(SpringConfig::GENTLE);
202    pub const EASE_QUICK: Self = Timing::Tween(TweenConfig::EASE_QUICK);
203    pub const EASE_STANDARD: Self = Timing::Tween(TweenConfig::EASE_STANDARD);
204    pub const EASE_EMPHASIZED: Self = Timing::Tween(TweenConfig::EASE_EMPHASIZED);
205}
206
207/// Identifies a specific animatable property on a node. Used as part
208/// of the per-(node, prop) tracker key.
209///
210/// Two families:
211///
212/// - **State envelopes** (`HoverAmount`, `PressAmount`, `FocusRingAlpha`)
213///   are 0..1 floats tracking *how much* of the corresponding state's
214///   visual delta is currently applied. The library updates these on
215///   every keyed interactive node automatically; no author opt-in. Why
216///   envelopes and not absolute colours: `apply_state` in `draw_ops`
217///   computes the display colour by lerping between `n.fill` and
218///   `state_color(n.fill)` based on the envelope. That keeps state
219///   easing completely independent of build-value changes — when the
220///   author swaps a button's fill mid-hover, the new fill takes effect
221///   instantly with the same hover envelope, no fighting between
222///   trackers.
223/// - **App-driven absolute values** (`App*`) are author-opted-in via
224///   [`crate::tree::El::animate`]. The tracker eases the value the build
225///   closure produces from the previous frame's value to the new one.
226#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
227#[non_exhaustive]
228pub enum AnimProp {
229    /// 0..1 amount of the hover-state visual delta currently applied.
230    /// Eases 0→1 on pointer enter, 1→0 on pointer leave.
231    HoverAmount,
232    /// 0..1 amount of the press-state visual delta currently applied.
233    /// Eases 0→1 on press, 1→0 on release.
234    PressAmount,
235    /// Focus-ring alpha — eases 0→1 on focus enter, 1→0 on focus leave.
236    /// Lets the ring fade out after focus moves elsewhere.
237    FocusRingAlpha,
238    /// 0..1 amount tracking "is the hover target this node or any
239    /// descendant?". Eases 0→1 when the cursor enters the subtree, 1→0
240    /// when it leaves. Drives region-shaped hover affordances
241    /// (`hover_alpha`, future hover-driven translate / scale / tint).
242    SubtreeHoverAmount,
243    /// 0..1 amount tracking "is the press target this node or any
244    /// descendant?". Subtree analogue of `PressAmount`.
245    SubtreePressAmount,
246    /// 0..1 amount tracking "is the focus target this node or any
247    /// descendant?". Subtree analogue of `FocusRingAlpha`. Composed
248    /// with `SubtreeHoverAmount` by `hover_alpha` so keyboard focus
249    /// reveals the same affordance hover does.
250    SubtreeFocusAmount,
251    /// App-driven fill colour — eases between the values the build
252    /// closure produces across rebuilds.
253    AppFill,
254    /// App-driven stroke colour.
255    AppStroke,
256    /// App-driven text colour.
257    AppTextColor,
258    /// App-driven paint-time alpha multiplier in `[0, 1]`.
259    AppOpacity,
260    /// App-driven uniform scale around the rect centre.
261    AppScale,
262    /// App-driven translate offset in logical pixels — X channel.
263    AppTranslateX,
264    /// App-driven translate offset in logical pixels — Y channel.
265    AppTranslateY,
266}
267
268// Settle thresholds vary by AnimValue type since their channels live in
269// very different magnitudes:
270//
271// - `AnimValue::Color` decomposes to Oklab (`L`, `a`, `b`, `alpha`) in
272//   roughly `[-1, 1]`. ~0.5 sRGB-u8 levels of channel difference corresponds
273//   to ~0.002 in Oklab L.
274// - `AnimValue::Float` is whatever the author put in — typically `[0, 1]`
275//   envelopes or logical-pixel translate offsets. The historical 0.5
276//   threshold was tuned for the pixel case and is comfortably below
277//   perceptual jitter for [0, 1] envelopes.
278const SPRING_EPSILON_DISP_COLOR: f32 = 0.002;
279const SPRING_EPSILON_VEL_COLOR: f32 = 0.005;
280const SPRING_EPSILON_DISP_FLOAT: f32 = 0.5;
281const SPRING_EPSILON_VEL_FLOAT: f32 = 0.5;
282const DT_CAP: f32 = 0.064;
283/// Hard upper bound on the per-substep timestep used inside `step_spring`.
284/// The semi-implicit Euler scheme with explicit damping is stable for
285/// `dt < 2·sqrt(m/k) + small damping correction`; the stiffest preset
286/// (`SpringConfig::QUICK`, k=380, c=30) has a stability bound near 58 ms.
287/// `DT_CAP` (64 ms) sits above that, so without substepping the integrator
288/// can blow up after long idle pauses or on slow frames — `current`
289/// overshoots into ±values and the 0..1 envelope `clamp` rounds to a
290/// binary flicker. 4 ms keeps every preset comfortably stable.
291const SPRING_MAX_SUBSTEP: f32 = 1.0 / 250.0;
292
293/// In-flight animation state for one (node, prop) pair. Stored on
294/// [`crate::state::UiState`] keyed by `(ComputedId, AnimProp)`.
295///
296/// `current` is the read-back view consumed by `write_prop` — for
297/// `AnimValue::Color` that's u8 rgba. The integrator's per-frame
298/// motion near equilibrium is sub-integer in rgb units (typical
299/// `vel * dt ≈ 0.1–0.4` once the spring is close to target), so
300/// integrating against the rounded view loses fractional progress
301/// every frame and the integrator freezes a few rgb units off
302/// target. `current_precise` is the lossless f32 mirror integrators
303/// actually read and write across ticks.
304#[derive(Clone, Debug)]
305#[non_exhaustive]
306pub struct Animation {
307    pub current: AnimValue,
308    pub target: AnimValue,
309    pub velocity: AnimChannels,
310    pub timing: Timing,
311    pub started_at: Instant,
312    pub last_step: Instant,
313    /// For tweens, the value at `started_at`. Springs are fully
314    /// determined by current+velocity, so `from` stays `None`.
315    pub from: Option<AnimValue>,
316    /// Lossless f32 mirror of `current` for the integrator. See struct
317    /// doc — `AnimValue::Color` stores u8, which silently freezes the
318    /// spring once per-frame motion drops below 0.5 rgb units.
319    current_precise: AnimChannels,
320}
321
322impl Animation {
323    pub fn new(current: AnimValue, target: AnimValue, timing: Timing, now: Instant) -> Self {
324        let channels = current.channels();
325        let n = channels.n;
326        let from = match timing {
327            Timing::Tween(_) => Some(current),
328            Timing::Spring(_) => None,
329        };
330        Self {
331            current,
332            target,
333            velocity: AnimChannels::zero(n),
334            timing,
335            started_at: now,
336            last_step: now,
337            from,
338            current_precise: channels,
339        }
340    }
341
342    /// Re-target a running animation. Current value and velocity carry
343    /// over so interrupted motion eases from where it is, not from rest.
344    /// For tweens, `from` snaps to the current sample so the new curve
345    /// starts there; the tween clock resets.
346    pub fn retarget(&mut self, target: AnimValue, now: Instant) {
347        if same_value(self.target, target) {
348            return;
349        }
350        self.target = target;
351        if matches!(self.timing, Timing::Tween(_)) {
352            self.from = Some(self.current);
353            self.started_at = now;
354        }
355        // Springs: keep current+velocity untouched. The integrator now
356        // sees a different `target` and forces will steer toward it.
357    }
358
359    /// Snap to target and zero velocity. Used by the headless bundle
360    /// path so SVG/PNG fixtures don't depend on integrator timing.
361    pub fn settle(&mut self) {
362        self.current = self.target;
363        self.current_precise = self.target.channels();
364        let n = self.current_precise.n;
365        self.velocity = AnimChannels::zero(n);
366        self.from = None;
367    }
368
369    /// Step the animation forward to `now`. Returns `true` if settled.
370    pub fn step(&mut self, now: Instant) -> bool {
371        let dt = now
372            .saturating_duration_since(self.last_step)
373            .as_secs_f32()
374            .min(DT_CAP);
375        self.last_step = now;
376        match self.timing {
377            Timing::Spring(cfg) => self.step_spring(cfg, dt),
378            Timing::Tween(cfg) => self.step_tween(cfg, now),
379        }
380    }
381
382    fn step_spring(&mut self, cfg: SpringConfig, dt: f32) -> bool {
383        if dt <= 0.0 {
384            return self.is_settled();
385        }
386        let (eps_disp, eps_vel) = self.target.settle_thresholds();
387        let mut cur = if self.current_precise.n == self.current.channels().n {
388            self.current_precise
389        } else {
390            self.current.channels()
391        };
392        let tgt = self.target.channels();
393        let mut vel = if self.velocity.n == cur.n {
394            self.velocity
395        } else {
396            AnimChannels::zero(cur.n)
397        };
398        // Substep so each integrator step is well within the stability
399        // bound for every SpringConfig preset. A single h = `dt` step
400        // would diverge for stiff presets when frames stall or the host
401        // resumes after a long idle (dt clamped to DT_CAP > stability
402        // bound for QUICK), producing binary 0/1 flicker once `current`
403        // overshoots into ±range and write_prop's clamp rounds it.
404        let n_steps = (dt / SPRING_MAX_SUBSTEP).ceil().max(1.0) as usize;
405        let h = dt / n_steps as f32;
406        let mut all_settled = false;
407        for _ in 0..n_steps {
408            all_settled = true;
409            for i in 0..cur.n {
410                let displacement = cur.v[i] - tgt.v[i];
411                let force = -cfg.stiffness * displacement - cfg.damping * vel.v[i];
412                // Semi-implicit Euler: update velocity first, then position
413                // using the new velocity. More stable than fully explicit
414                // for stiff systems within UI's typical stiffness range.
415                vel.v[i] += (force / cfg.mass) * h;
416                cur.v[i] += vel.v[i] * h;
417                if displacement.abs() > eps_disp || vel.v[i].abs() > eps_vel {
418                    all_settled = false;
419                }
420            }
421            if all_settled {
422                break;
423            }
424        }
425        if all_settled {
426            self.current = self.target;
427            self.current_precise = tgt;
428            self.velocity = AnimChannels::zero(cur.n);
429            return true;
430        }
431        self.current_precise = cur;
432        self.current = self.current.from_channels(cur);
433        self.velocity = vel;
434        false
435    }
436
437    fn step_tween(&mut self, cfg: TweenConfig, now: Instant) -> bool {
438        let elapsed = now.saturating_duration_since(self.started_at);
439        if elapsed >= cfg.duration {
440            self.current = self.target;
441            self.current_precise = self.target.channels();
442            return true;
443        }
444        let from = self.from.unwrap_or(self.current).channels();
445        let tgt = self.target.channels();
446        let t = elapsed.as_secs_f32() / cfg.duration.as_secs_f32();
447        let eased = cubic_bezier_y_at_x(t, cfg.p1, cfg.p2);
448        let mut next = AnimChannels {
449            n: from.n,
450            v: [0.0; 4],
451        };
452        for i in 0..from.n {
453            next.v[i] = from.v[i] + (tgt.v[i] - from.v[i]) * eased;
454        }
455        self.current_precise = next;
456        self.current = self.current.from_channels(next);
457        false
458    }
459
460    fn is_settled(&self) -> bool {
461        let (_, eps_vel) = self.target.settle_thresholds();
462        same_value(self.current, self.target)
463            && (0..self.velocity.n).all(|i| self.velocity.v[i].abs() <= eps_vel)
464    }
465}
466
467fn same_value(a: AnimValue, b: AnimValue) -> bool {
468    let ca = a.channels();
469    let cb = b.channels();
470    if ca.n != cb.n {
471        return false;
472    }
473    (0..ca.n).all(|i| (ca.v[i] - cb.v[i]).abs() < f32::EPSILON)
474}
475
476/// Solve `cubic_bezier(t).x == x` for `t`, then return `cubic_bezier(t).y`.
477/// P0=(0,0), P3=(1,1). Newton-Raphson with binary-search fallback.
478fn cubic_bezier_y_at_x(x: f32, p1: (f32, f32), p2: (f32, f32)) -> f32 {
479    if x <= 0.0 {
480        return 0.0;
481    }
482    if x >= 1.0 {
483        return 1.0;
484    }
485    // Newton-Raphson on x(t) — converges in 4-6 iterations for typical
486    // ease curves. Fall back to bisection if the derivative collapses.
487    let mut t = x;
488    for _ in 0..8 {
489        let xt = bezier_axis(t, p1.0, p2.0);
490        let dx = bezier_axis_derivative(t, p1.0, p2.0);
491        if dx.abs() < 1e-6 {
492            break;
493        }
494        let next = t - (xt - x) / dx;
495        if (next - t).abs() < 1e-5 {
496            t = next.clamp(0.0, 1.0);
497            break;
498        }
499        t = next.clamp(0.0, 1.0);
500    }
501    bezier_axis(t, p1.1, p2.1)
502}
503
504/// Cubic Bezier polynomial: B(t) = 3·(1-t)²·t·c1 + 3·(1-t)·t²·c2 + t³.
505/// P0 and P3 are pinned at 0 and 1 (no contribution beyond the t³ term).
506fn bezier_axis(t: f32, c1: f32, c2: f32) -> f32 {
507    let one_minus_t = 1.0 - t;
508    3.0 * one_minus_t * one_minus_t * t * c1 + 3.0 * one_minus_t * t * t * c2 + t * t * t
509}
510
511fn bezier_axis_derivative(t: f32, c1: f32, c2: f32) -> f32 {
512    let one_minus_t = 1.0 - t;
513    3.0 * one_minus_t * one_minus_t * c1
514        + 6.0 * one_minus_t * t * (c2 - c1)
515        + 3.0 * t * t * (1.0 - c2)
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    fn now_plus(start: Instant, ms: u64) -> Instant {
523        start + Duration::from_millis(ms)
524    }
525
526    #[test]
527    fn spring_settles_to_target() {
528        let start = Instant::now();
529        let mut a = Animation::new(
530            AnimValue::Float(0.0),
531            AnimValue::Float(1.0),
532            Timing::SPRING_QUICK,
533            start,
534        );
535        let mut t = start;
536        for _ in 0..200 {
537            t += Duration::from_millis(8);
538            if a.step(t) {
539                break;
540            }
541        }
542        let AnimValue::Float(v) = a.current else {
543            panic!("expected float")
544        };
545        assert!((v - 1.0).abs() < 1e-3, "spring did not settle: v={v}");
546    }
547
548    #[test]
549    fn spring_retarget_preserves_velocity() {
550        // Start moving 0 → 1; mid-flight retarget back to 0 should
551        // briefly continue past the new target before reversing —
552        // momentum carries.
553        let start = Instant::now();
554        let mut a = Animation::new(
555            AnimValue::Float(0.0),
556            AnimValue::Float(1.0),
557            Timing::SPRING_STANDARD,
558            start,
559        );
560        let mut t = start;
561        for _ in 0..15 {
562            t += Duration::from_millis(8);
563            a.step(t);
564        }
565        let mid = match a.current {
566            AnimValue::Float(v) => v,
567            _ => unreachable!(),
568        };
569        assert!(mid > 0.0 && mid < 1.0, "expected mid-flight, got {mid}");
570        let velocity_before = a.velocity.v[0];
571        assert!(velocity_before > 0.0);
572        a.retarget(AnimValue::Float(0.0), t);
573        // Velocity is preserved — the spring will continue forward briefly.
574        assert_eq!(a.velocity.v[0], velocity_before);
575    }
576
577    #[test]
578    fn tween_samples_endpoints() {
579        let start = Instant::now();
580        let mut a = Animation::new(
581            AnimValue::Float(10.0),
582            AnimValue::Float(20.0),
583            Timing::EASE_STANDARD,
584            start,
585        );
586        a.step(start);
587        let AnimValue::Float(v0) = a.current else {
588            panic!()
589        };
590        assert!(
591            (v0 - 10.0).abs() < 1e-3,
592            "tween at t=0 should equal `from`, got {v0}"
593        );
594
595        a.step(now_plus(start, 1000));
596        let AnimValue::Float(vend) = a.current else {
597            panic!()
598        };
599        assert!(
600            (vend - 20.0).abs() < 1e-3,
601            "tween past duration should equal target, got {vend}"
602        );
603    }
604
605    #[test]
606    fn tween_retarget_snaps_from_to_current() {
607        let start = Instant::now();
608        let mut a = Animation::new(
609            AnimValue::Float(0.0),
610            AnimValue::Float(100.0),
611            Timing::EASE_STANDARD,
612            start,
613        );
614        a.step(now_plus(start, 100));
615        let AnimValue::Float(mid) = a.current else {
616            panic!()
617        };
618        a.retarget(AnimValue::Float(0.0), now_plus(start, 100));
619        assert_eq!(a.from, Some(AnimValue::Float(mid)));
620    }
621
622    #[test]
623    fn settle_snaps_to_target() {
624        let start = Instant::now();
625        let mut a = Animation::new(
626            AnimValue::Color(Color::srgb_u8a(0, 0, 0, 255)),
627            AnimValue::Color(Color::srgb_u8a(255, 128, 0, 255)),
628            Timing::SPRING_STANDARD,
629            start,
630        );
631        a.step(now_plus(start, 5));
632        a.settle();
633        match a.current {
634            AnimValue::Color(c) => {
635                assert_eq!(c.to_srgb_u8a(), [255, 128, 0, 255]);
636            }
637            _ => panic!("expected color"),
638        }
639        assert!(a.velocity.v.iter().all(|&v| v == 0.0));
640    }
641
642    #[test]
643    fn cubic_bezier_endpoints_pin() {
644        // Any curve must satisfy P(0)=0 and P(1)=1.
645        let p1 = (0.4, 0.0);
646        let p2 = (0.2, 1.0);
647        assert!((cubic_bezier_y_at_x(0.0, p1, p2) - 0.0).abs() < 1e-3);
648        assert!((cubic_bezier_y_at_x(1.0, p1, p2) - 1.0).abs() < 1e-3);
649    }
650
651    #[test]
652    fn color_channels_round_trip() {
653        // Channels are Oklab (L, a, b, alpha) so spring physics
654        // interpolates perceptually. Round trip via the same Color's
655        // space recovers the input to within float precision.
656        let c = Color::srgb_u8a(42, 17, 200, 255);
657        let v = AnimValue::Color(c);
658        let ch = v.channels();
659        assert_eq!(ch.n, 4);
660        let back = v.from_channels(ch);
661        let AnimValue::Color(back) = back else {
662            panic!("expected color");
663        };
664        let [r, g, b, a] = back.to_srgb_u8a();
665        assert_eq!(
666            [r, g, b, a],
667            [42, 17, 200, 255],
668            "round-trip should recover the source rgba within u8 precision"
669        );
670    }
671
672    #[test]
673    fn from_channels_drops_token_on_in_flight_eased_value() {
674        // An in-flight eased rgba is not the same color as the source
675        // token — keeping the token name on it would let palette
676        // resolution snap the rgb back to the source token's palette
677        // value, killing the transition. Spring/tween settled paths
678        // bypass `from_channels` and assign `self.current = self.target`
679        // directly, so settled values still carry the target's token.
680        let v = AnimValue::Color(Color::srgb_token("primary", 92, 170, 255, 255));
681        // Mid-flight: synthesize a halfway Oklab between the source and
682        // a different target. Channel semantics are Oklab (L, a, b, alpha).
683        let start = Color::srgb_u8(92, 170, 255).to_oklab();
684        let end = Color::srgb_u8(255, 100, 80).to_oklab();
685        let mid_lab = Oklab {
686            l: (start.l + end.l) * 0.5,
687            a: (start.a + end.a) * 0.5,
688            b: (start.b + end.b) * 0.5,
689            alpha: 1.0,
690        };
691        let mid = AnimChannels {
692            n: 4,
693            v: [mid_lab.l, mid_lab.a, mid_lab.b, mid_lab.alpha],
694        };
695        let eased = v.from_channels(mid);
696        match eased {
697            AnimValue::Color(c) => {
698                assert_eq!(c.token, None, "in-flight eased color must drop the token");
699                // The mid-flight value must lie strictly between start
700                // and end on each Oklab axis (perceptually mid).
701                let lab = c.to_oklab();
702                let lo_l = start.l.min(end.l);
703                let hi_l = start.l.max(end.l);
704                assert!(lab.l >= lo_l && lab.l <= hi_l, "L out of range");
705            }
706            _ => panic!("expected color"),
707        }
708    }
709}