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}