cranpose-liquid 0.1.83

Liquid UI — cranpose's first-party glass component library (iOS-26-style materials, spring motion)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
//! The iOS 26 switch: a 63×28 capsule track with a wide capsule thumb
//! (~60% of the track). Pressing lifts the whole thumb into a transparent
//! refractive glass capsule that grows past the track edges (the reference
//! "toggle in action" frames): the track color refracts through it with a
//! rainbow rim while the white thumb dissolves into the glass. Releasing
//! lets the lens shrink back slowly; the white thumb rematerializes at the
//! end of the settle. The thumb is swipable; a tap flips.

use crate::material::{
    Glass, GlassDynamics, GlassMorph, GlassShadow, LiquidModifierExt, LiquidShape,
};
use crate::motion::LiquidMotion;
use crate::theme::liquid_colors;
use cranpose_animation::{
    animateColorAsState, animateFloatAsState, spring, AnimationSpec, AnimationType, Easing,
};
use cranpose_core::{mutableStateOf, remember};
use cranpose_macros::composable;
use cranpose_services::{default_haptics, HapticFeedback};
use cranpose_ui::widgets::{Box, BoxSpec};
use cranpose_ui::{Modifier, PointerEventKind, PointerInputScope, Size};
use cranpose_ui_graphics::{Brush, CornerRadii, GraphicsLayer};

pub(crate) const TRACK_WIDTH: f32 = 63.0;
pub(crate) const TRACK_HEIGHT: f32 = 28.0;
const THUMB_WIDTH: f32 = 37.0;
const THUMB_HEIGHT: f32 = 25.0;
const THUMB_MARGIN: f32 = 1.5;
/// The pressed dome anchored to the 63dp track (user-corrected twice):
/// the chromatic ring spans ~44 x 27dp (T133/T266 at 6.27 px/dp) — a
/// BIG dome leaning toward the travel side with the gray sticking out
/// its trailing edge. The "thinner" read is OPTICAL: the rim band
/// compresses the transmitted content inward so the gray under the
/// glass slims, while the dome itself stays grand. A small dome was the
/// wrong fix; a spilling halo was the original defect.
/// A WIDE GRAND capsule, not a round dome: the reference hold silhouette
/// is ~1.5 wider than tall (interior aspect 1.41, rim ring 1.78 measured
/// on the hold tile). The dome stays grand while the OPTICS thin the gray
/// content inside it (user: "the dome itself stays grand"); a 44dp width
/// read too circular against the 36dp height.
const LENS_WIDTH: f32 = 54.0;
/// TALLER than the 28dp track: the pressed dome pokes ~4dp past both bar
/// edges (user: "the glass bubble should be wider in height than the
/// toggle itself").
const LENS_HEIGHT: f32 = 36.0;
/// Centered on the track so the dome pokes symmetrically past the top
/// and bottom edges.
const LENS_VERTICAL_OFFSET: f32 = 0.0;
/// The raised lens leans toward the travel side, measured from the thumb
/// center on the reference press and settle frames (~6-8dp in every phase:
/// press leans toward the destination, flight leads the thumb, settle
/// overhangs the arrival end).
const LENS_TRAVEL_LEAN: f32 = 7.0;
/// Glass node span beyond the lens shape (rim glow + wobble live here).
/// The full dome plus the travel lean needs real headroom — 10dp cut the
/// leaning edge flat (live report).
const LENS_PAD: f32 = 18.0;
/// Pointer travel below this is a tap, not a swipe.
const TAP_SLOP: f32 = 4.0;
// The flight itself now carries the raised lens; the reference keeps the
// lens clearly readable ~750 ms after release (toggle-press sheet still
// shows it at 883 ms) before the thumb rematerializes.
const LENS_RELEASE_LINGER_MS: u64 = 400;
const LENS_RELEASE_FADE_MS: u64 = 400;

fn toggle_track_motion() -> AnimationType {
    // The reference track crosses into sage ~17 ms after release and reaches
    // full green ~150 ms later (toggle-press sheet, 60 fps): a fast-start
    // sweep with no delay. EaseOut at 150 front-loaded the change and read
    // fully green ~70 ms in (sheet: ours full at 231 ms vs target 283-300);
    // 260 keeps the instant sage onset while the tail spans the reference's
    // ~150 ms of visible progression.
    AnimationType::Tween(AnimationSpec::tween(260, Easing::EaseOut))
}

fn toggle_lens_material() -> Glass {
    // ONE continuous light path (example/shaders.txt wcKSRD etalon): a
    // single `sin(pow(clamp(-sdf/refraction), 0.25) * 1.57)` displacement
    // field, blurred, with the etalon's border + gradient lighting. The
    // ONLY honest chromatic addition is tracing that SAME field per RGB
    // channel at its own refractive index (dispersion) — real material
    // aberration, not a separate spectral band. No fold crutch, no zoom
    // chamber, no meniscus/absorption overrides, no leading-edge content
    // gather: those layered "cratches" read as cheated compound parts.
    // The reference press look is this etalon field over the pressed
    // content (white-washed face + gray blob).
    Glass::lens()
        .shape(LiquidShape::Capsule)
        .tint(cranpose_ui_graphics::Color::WHITE.with_alpha(0.02))
        .blur_radius(0.8)
        .saturation(1.0)
        .refraction_depth(0.55)
        .refraction_curve(0.25)
        .transmission_refraction(1.0)
        .dispersion(0.9)
        .highlight(0.04)
        .lift(0.16)
        .shadow_style(GlassShadow::new(
            cranpose_ui_graphics::Color::BLACK.with_alpha(0.14),
            14.0,
            4.0,
            -1.5,
        ))
        .no_clip()
}

fn toggle_motion_bulge(pose: crate::dynamics::LiquidPose) -> f32 {
    pose.bulge_amplitude.max(3.5 * pose.energy()).min(5.0)
}

fn toggle_lens_release() -> AnimationType {
    AnimationType::Tween(
        AnimationSpec::tween(LENS_RELEASE_FADE_MS, Easing::EaseIn)
            .with_delay(LENS_RELEASE_LINGER_MS),
    )
}

fn track_tint_progress(progress: f32) -> f32 {
    // Direct contact never paints the committed endpoint color. The whole
    // fixed capsule moves through gray/sage together; release owns the final
    // transition into system green.
    let t = ((progress.clamp(0.0, 1.0) - 0.20) / 1.25).clamp(0.0, 1.0);
    t * t * (3.0 - 2.0 * t)
}

fn interpolate_track_color(
    source: cranpose_ui_graphics::Color,
    target: cranpose_ui_graphics::Color,
    progress: f32,
) -> cranpose_ui_graphics::Color {
    let progress = progress.clamp(0.0, 1.0);
    cranpose_ui_graphics::Color::rgba(
        source.r() + (target.r() - source.r()) * progress,
        source.g() + (target.g() - source.g()) * progress,
        source.b() + (target.b() - source.b()) * progress,
        source.a() + (target.a() - source.a()) * progress,
    )
}

fn lens_translation_x(thumb_x: f32, node_width: f32) -> f32 {
    thumb_x + (THUMB_WIDTH - node_width) * 0.5
}

/// The reference track is a recessed WELL, not a flat fill (rest frame
/// f_001, center column at 3x): a cool-bright blue-tinted edge fading over
/// ~2.5dp at the top, the base face, and a warm bright lip peaking ~1.5dp
/// above the bottom edge with a neutral seam under it. These chromatic
/// edges are exactly what the pressed dome's rim band re-images into its
/// blue top / orange bottom arcs — the flat fill starved the ring.
fn track_well_brush(track: cranpose_ui_graphics::Color) -> Brush {
    let scale = |c: cranpose_ui_graphics::Color, r: f32, g: f32, b: f32| {
        cranpose_ui_graphics::Color::rgba(
            (c.r() * r).min(1.0),
            (c.g() * g).min(1.0),
            (c.b() * b).min(1.0),
            c.a(),
        )
    };
    // A whisper of a recess, not a lit lip: the strong bottom lip
    // (1.46x) re-imaged by the dome as a weird bright highlight. The
    // reference well is nearly flat with only a faint cool top edge.
    let cool_top = scale(track, 1.05, 1.07, 1.09);
    let lip = scale(track, 1.08, 1.07, 1.04);
    let seam = scale(track, 1.02, 1.02, 1.0);
    Brush::vertical_gradient_stops(
        vec![
            (0.0, cool_top),
            (0.09, track),
            (0.86, track),
            (0.945, lip),
            (1.0, seam),
        ],
        0.0,
        TRACK_HEIGHT,
        cranpose_ui_graphics::TileMode::Clamp,
    )
}

/// The lean's travel side for a fresh press: the only end this switch can
/// head to. Movement and release retarget it through the gesture handler —
/// the fluid motion axis is unusable here because a slow drag stays under
/// its direction threshold and holds whatever the previous flight left.
fn lens_press_travel(checked: bool) -> f32 {
    if checked {
        -1.0
    } else {
        1.0
    }
}

fn lens_ride_x(drag_progress: Option<f32>, thumb_x: f32) -> f32 {
    drag_progress
        .map(|progress| {
            let min_x = THUMB_MARGIN;
            let max_x = TRACK_WIDTH - THUMB_MARGIN - THUMB_WIDTH;
            min_x + (max_x - min_x) * progress.clamp(0.0, 1.0)
        })
        .unwrap_or(thumb_x)
}

/// An on/off switch. `checked` is owned by the caller; `on_change` receives
/// the requested new value. The thumb both taps and swipes.
#[composable]
#[allow(non_snake_case)]
pub fn LiquidToggle(modifier: Modifier, checked: bool, on_change: impl Fn(bool) + 'static) {
    let colors = liquid_colors();

    // Some(progress 0..1) while the finger drags the thumb.
    let drag_progress = remember(|| mutableStateOf(Option::<f32>::None)).with(|s| *s);
    let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
    // ±1: which end the gesture is heading to. Press aims at the flip side,
    // movement follows the finger, release holds the committed side through
    // the linger (the settled lens keeps overhanging its arrival end).
    let travel_dir = remember(|| mutableStateOf(1.0f32)).with(|s| *s);
    let off_track = colors.toggle_off;
    // Mid-drag the complete track interpolates with the finger. Its capsule
    // geometry is fixed; the glass is a separate optical layer above it.
    let base_track = match drag_progress.get() {
        Some(progress) => {
            interpolate_track_color(off_track, colors.toggle_on, track_tint_progress(progress))
        }
        None => {
            if checked {
                colors.toggle_on
            } else {
                off_track
            }
        }
    };
    // The uncovered tail retains the system accent. The lens itself creates
    // the lighter, lower-saturation interior through refraction; applying
    // that tone transform to the whole track washes out the reference color.
    let animated_track = animateColorAsState(base_track, toggle_track_motion(), "toggle-track");
    let track_color = if drag_progress.get().is_some() {
        base_track
    } else {
        animated_track.get()
    };

    let min_x = THUMB_MARGIN;
    let max_x = TRACK_WIDTH - THUMB_MARGIN - THUMB_WIDTH;

    // While dragging, the spring target follows the finger: the thumb trails
    // it with a droplet lag; on release it continues into the settle from the
    // same spring state, velocity preserved.
    let target_x = match drag_progress.get() {
        Some(progress) => min_x + (max_x - min_x) * progress,
        None => {
            if checked {
                max_x
            } else {
                min_x
            }
        }
    };
    let thumb_x = animateFloatAsState(target_x, LiquidMotion::snappy(), "toggle-thumb-x");
    let lens_axis = crate::motion::remember_liquid_drag_axis(target_x);
    lens_axis.settle_to(target_x, LiquidMotion::snappy());
    let lens_x = lens_axis.value();

    // Lens presence: springs to 1 fast on press (the glass materializes in
    // ~120ms), decays slowly after release (the reference lens lingers
    // through the settle flight for ~0.6s before the white thumb returns).
    // A quick TAP holds the lens raised through the whole flip flight —
    // the reference shows the full glass riding the thumb to the far end,
    // not a faint trace of a barely-risen press.
    let thumb_in_flight = (thumb_x.get() - target_x).abs() > 1.5;
    let lens_target = if pressed.get() || thumb_in_flight {
        1.0
    } else {
        0.0
    };
    let lens_progress = animateFloatAsState(
        lens_target,
        if pressed.get() || thumb_in_flight {
            spring(0.9, 1400.0)
        } else {
            toggle_lens_release()
        },
        "toggle-lens",
    );
    // Dome press physics: the held dome is squashed deep (wide vivid rim
    // band — the reference gray-hold rainbow); on release it relaxes to a
    // shallow bead over the flight (the reference's thin settled ring with
    // faint sparks). Raised-ness (lens_progress) and press depth are
    // distinct: the lens stays raised through the whole flight while its
    // optics relax the moment the finger lifts.
    let press_depth = animateFloatAsState(
        if pressed.get() { 1.0 } else { 0.45 },
        AnimationType::Tween(AnimationSpec::tween(120, Easing::EaseOut)),
        "toggle-press-depth",
    );

    let on_change = std::rc::Rc::new(on_change);
    let track = Modifier::empty()
        .size(Size::new(TRACK_WIDTH, TRACK_HEIGHT))
        // The controlled value is part of the gesture identity. Once a
        // release commits a new value, the next gesture must capture that
        // value; keeping one coroutine under a constant key leaves `checked`
        // frozen at the first composition and prevents reversing the switch.
        .pointer_input(checked, {
            let on_change = std::rc::Rc::clone(&on_change);
            let lens_axis = std::rc::Rc::clone(&lens_axis);
            move |scope: PointerInputScope| {
                let on_change = std::rc::Rc::clone(&on_change);
                let lens_axis = std::rc::Rc::clone(&lens_axis);
                async move {
                    scope
                        .await_pointer_event_scope(|await_scope| async move {
                            let mut down_x = 0.0f32;
                            let mut grab_offset = 0.0f32;
                            let mut dragging = false;
                            loop {
                                let event = await_scope.await_pointer_event().await;
                                match event.kind {
                                    PointerEventKind::Down => {
                                        dragging = true;
                                        down_x = event.position.x;
                                        // The finger grabs the thumb WHERE it
                                        // touched it: without this offset the
                                        // first move snaps the thumb center
                                        // under the finger (live report).
                                        grab_offset =
                                            event.position.x - (thumb_x.get() + THUMB_WIDTH * 0.5);
                                        lens_axis.begin(thumb_x.get(), event.time_ms);
                                        pressed.set(true);
                                        travel_dir.set(lens_press_travel(checked));
                                        default_haptics().perform(HapticFeedback::Selection);
                                        event.consume();
                                    }
                                    PointerEventKind::Move if dragging => {
                                        let progress = ((event.position.x
                                            - grab_offset
                                            - THUMB_MARGIN
                                            - THUMB_WIDTH * 0.5)
                                            / (TRACK_WIDTH - 2.0 * THUMB_MARGIN - THUMB_WIDTH))
                                            .clamp(0.0, 1.0);
                                        if let Some(previous) = drag_progress.get() {
                                            if (progress - previous).abs() > 0.005 {
                                                travel_dir.set((progress - previous).signum());
                                            }
                                        }
                                        drag_progress.set(Some(progress));
                                        lens_axis.move_to(
                                            lens_ride_x(Some(progress), thumb_x.get()),
                                            event.time_ms,
                                        );
                                        event.consume();
                                    }
                                    PointerEventKind::Up if dragging => {
                                        dragging = false;
                                        pressed.set(false);
                                        let travelled =
                                            (event.position.x - down_x).abs() > TAP_SLOP;
                                        let next = if travelled {
                                            drag_progress
                                                .get()
                                                .map(|p| p >= 0.5)
                                                .unwrap_or(!checked)
                                        } else {
                                            !checked
                                        };
                                        travel_dir.set(if next { 1.0 } else { -1.0 });
                                        lens_axis.release_to(
                                            if next { max_x } else { min_x },
                                            event.time_ms,
                                            LiquidMotion::glide(),
                                        );
                                        drag_progress.set(None);
                                        if next != checked {
                                            default_haptics().perform(HapticFeedback::ImpactLight);
                                            on_change(next);
                                        }
                                        event.consume();
                                    }
                                    PointerEventKind::Cancel if dragging => {
                                        dragging = false;
                                        pressed.set(false);
                                        travel_dir.set(if checked { 1.0 } else { -1.0 });
                                        lens_axis.release_to(
                                            if checked { max_x } else { min_x },
                                            event.time_ms,
                                            LiquidMotion::snappy(),
                                        );
                                        drag_progress.set(None);
                                        event.consume();
                                    }
                                    _ => {}
                                }
                            }
                        })
                        .await;
                }
            }
        })
        .draw_behind(move |scope| {
            // The track is a solid recessed well that only INTERPOLATES its
            // color (gray -> green). It must never carry moving content: a
            // press-time white wash + a gray blob made the backdrop slide
            // with the dome during a drag instead of just changing color.
            scope.draw_round_rect(
                track_well_brush(track_color),
                CornerRadii::uniform(TRACK_HEIGHT * 0.5),
            );
        });

    Box(track.then(modifier), BoxSpec::default(), move || {
        let thumb_x_for_layer = thumb_x;
        let lens_for_thumb = lens_progress;
        // Resting thumb: a plain white capsule. It dissolves into the glass
        // as the lens rises and rematerializes near the settle's end, so
        // while the dome rides there is only the uniform track color beneath
        // it — the refraction distorts nothing that reads as sliding
        // background.
        let thumb = Modifier::empty()
            .size(Size::new(THUMB_WIDTH, THUMB_HEIGHT))
            .offset(0.0, (TRACK_HEIGHT - THUMB_HEIGHT) * 0.5)
            .graphics_layer(move || {
                let lens = lens_for_thumb.get();
                let alpha = ((0.30 - lens) / 0.22).clamp(0.0, 1.0);
                GraphicsLayer {
                    translation_x: thumb_x_for_layer.get(),
                    alpha,
                    ..Default::default()
                }
            })
            // A soft whisper lifting the thumb off the track (the reference
            // thumb floats; nothing dark).
            .drop_shadow(
                cranpose_ui_graphics::LayerShape::Rounded(
                    cranpose_ui_graphics::RoundedCornerShape::uniform(THUMB_HEIGHT * 0.5),
                ),
                |scope| {
                    scope.radius = 2.0;
                    scope.offset.y = 0.5;
                    scope.color = cranpose_ui_graphics::Color::BLACK.with_alpha(0.10);
                },
            )
            .draw_behind(move |scope| {
                scope.draw_round_rect(
                    Brush::solid(cranpose_ui_graphics::Color::WHITE),
                    CornerRadii::uniform(THUMB_HEIGHT * 0.5),
                );
            });
        Box(thumb, BoxSpec::default(), || {});

        // The interaction lens: one glass node riding the thumb; its SDF
        // capsule inflates from thumb-size to the full lens with a viscous
        // bulge along the drag direction. The morph (not a layer scale)
        // grows it so refraction, rim and wobble stay physically coherent.
        let deformation_headroom =
            crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
        let node_w =
            LENS_WIDTH * deformation_headroom + crate::dynamics::BULGE_MAX + LENS_PAD * 2.0;
        let node_h =
            LENS_HEIGHT * deformation_headroom + crate::dynamics::BULGE_MAX + LENS_PAD * 2.0;
        let lens_for_layer = lens_progress;
        let physics_axis = std::rc::Rc::clone(&lens_axis);
        let lens = Modifier::empty()
            // required_size: the lens node MUST exceed the 63×28 track
            // box — plain size() would be coerced by the parent's
            // constraints, clamping the SDF and slicing the lens.
            .required_size(Size::new(node_w, node_h))
            .offset(0.0, (TRACK_HEIGHT - node_h) * 0.5 + LENS_VERTICAL_OFFSET)
            .graphics_layer(move || GraphicsLayer {
                translation_x: lens_translation_x(lens_x, node_w),
                ..Default::default()
            })
            .glass_effect_with(toggle_lens_material(), move || {
                let grow = lens_for_layer.get().clamp(0.0, 1.2);
                let base_w = THUMB_WIDTH + (LENS_WIDTH - THUMB_WIDTH) * grow;
                let base_h = THUMB_HEIGHT + (LENS_HEIGHT - THUMB_HEIGHT) * grow;
                // Droplet law over the ride position: drag speed
                // stretches the lens along the track, braking swells
                // its leading edge (crate::dynamics).
                let pose = physics_axis.liquid_pose();
                // The lens leans toward its travel side in every raised
                // phase (the reference press leans toward the destination,
                // the settle overhangs the arrival end). The lean lives in
                // the SDF center so the optics tilt while the node holds.
                let lean = travel_dir.get() * LENS_TRAVEL_LEAN * grow.clamp(0.0, 1.0);
                GlassDynamics {
                    activity: Some(grow.clamp(0.0, 1.0)),
                    press_depth: Some(press_depth.get()),
                    morph: Some(GlassMorph {
                        node_size: (node_w, node_h),
                        primary: (node_w * 0.5 + lean, node_h * 0.5, base_w, base_h, -1.0),
                        shapes: Vec::new(),
                        glue: 0.0,
                        wobble_amplitude: 0.0,
                        wobble_phase: 0.0,
                        bulge_amplitude: toggle_motion_bulge(pose),
                        bulge_direction: pose.bulge_direction,
                        ellipse_blend: 0.0,
                        deformation: Some(pose.deformation()),
                        // The magnification stays anchored on the thumb the
                        // lens rides: the leaning silhouette must not drag
                        // the optical axis toward the empty well (the white
                        // bloom on the trailing face).
                        zoom_anchor: (-lean, 0.0),
                    }),
                    ..Default::default()
                }
            });
        Box(lens, BoxSpec::default(), || {});
    });
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn toggle_geometry_matches_the_reference_proportions() {
        assert_eq!((TRACK_WIDTH, TRACK_HEIGHT), (63.0, 28.0));
        assert_eq!((THUMB_WIDTH, THUMB_HEIGHT), (37.0, 25.0));
        // Reference hold (6.27 px/dp): ring ~44 x 27dp — a big dome,
        // wider than the thumb (the gray sticks out via the LEAN), still
        // inside the track vertically.
        assert_eq!((LENS_WIDTH, LENS_HEIGHT), (54.0, 36.0));
        const { assert!(LENS_WIDTH > THUMB_WIDTH) };
        const { assert!(LENS_HEIGHT > TRACK_HEIGHT) };
        assert_eq!(
            toggle_lens_material().shape,
            LiquidShape::Capsule,
            "the pressed switch thumb remains a capsule while its optical body inflates"
        );
        // The dome rides high enough to expose the white face strip under
        // its top rim (reference f_009: ~1.3dp of washed face between dome
        // top and blob top feeds the luminous band).
        assert!(LENS_VERTICAL_OFFSET.abs() < 1.0e-6);

        let mid = interpolate_track_color(
            cranpose_ui_graphics::Color::from_rgb_u8(187, 186, 188),
            cranpose_ui_graphics::Color::from_rgb_u8(43, 189, 76),
            0.5,
        );
        assert!((mid.r() - 115.0 / 255.0).abs() < 1.0e-6);
        assert!((mid.g() - 187.5 / 255.0).abs() < 1.0e-6);
        assert!((mid.b() - 132.0 / 255.0).abs() < 1.0e-6);
    }

    #[test]
    fn toggle_lens_leans_toward_the_travel_side() {
        // T133 detail geometry: ~12dp of gray outside the trailing rim with
        // the 44dp dome -> the center leans ~13dp into the travel.
        assert_eq!(LENS_TRAVEL_LEAN, 7.0);
        // A fresh press has no motion yet: the only travel side is the
        // opposite end of the track.
        assert_eq!(lens_press_travel(false), 1.0);
        assert_eq!(lens_press_travel(true), -1.0);

        // Mid-drag the leaning lens frees the departed track region: its
        // trailing edge must clear the whole-track interpolation samples
        // (the traveling-fill contract probes 5dp in from the track end).
        let min = THUMB_MARGIN;
        let max = TRACK_WIDTH - THUMB_MARGIN - THUMB_WIDTH;
        let mid_thumb_center = (min + max) * 0.5 + THUMB_WIDTH * 0.5;
        let lens_trailing_edge = mid_thumb_center + LENS_TRAVEL_LEAN - LENS_WIDTH * 0.5;
        assert!(lens_trailing_edge > 5.0);

        // The node itself never leans — the lean lives in the SDF center so
        // the oversized node's padding absorbs it on both sides.
        let node_width = LENS_WIDTH
            * crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN)
            + crate::dynamics::BULGE_MAX
            + LENS_PAD * 2.0;
        let node_left = lens_translation_x(max, node_width);
        let node_center = node_left + node_width * 0.5;
        assert!((node_center - (max + THUMB_WIDTH * 0.5)).abs() < 1.0e-5);
        assert!(node_width * 0.5 - LENS_WIDTH * 0.5 - LENS_TRAVEL_LEAN > 0.0);
    }

    #[test]
    fn toggle_lens_ride_uses_pointer_progress_while_dragging() {
        assert_eq!(lens_ride_x(Some(0.0), 20.0), THUMB_MARGIN);
        assert_eq!(
            lens_ride_x(Some(1.0), THUMB_MARGIN),
            TRACK_WIDTH - THUMB_MARGIN - THUMB_WIDTH
        );
        assert_eq!(lens_ride_x(None, 12.5), 12.5);
    }

    #[test]
    fn toggle_track_tint_waits_for_real_travel() {
        assert_eq!(track_tint_progress(0.20), 0.0);
        assert!(track_tint_progress(0.35) < 0.2);
        assert!((0.30..=0.40).contains(&track_tint_progress(0.70)));
        assert!((0.65..=0.75).contains(&track_tint_progress(1.0)));
    }

    #[test]
    fn toggle_track_color_sweeps_on_the_reference_clock() {
        // Measured on the toggle-press sheet (60 fps): sage appears ~17 ms
        // after release and the visible sweep spans ~150 ms (full green at
        // 283-300 ms) — no delay, fast start, EaseOut tail stretched so the
        // front-loaded curve doesn't finish the visible change in ~70 ms.
        let AnimationType::Tween(spec) = toggle_track_motion() else {
            panic!("toggle track color needs a bounded transition");
        };
        assert_eq!(spec.delay_millis, 0);
        assert_eq!(spec.duration_millis, 260);
        assert_eq!(spec.easing, Easing::EaseOut);
    }

    #[test]
    fn toggle_silhouette_uses_reciprocal_shader_deformation() {
        let pose = crate::dynamics::LiquidPose {
            stretch: 1.25,
            ortho: 0.8,
            axis: (1.0, 0.0),
            ..Default::default()
        };
        let deformation = pose.deformation();
        assert_eq!(deformation.along(), 1.25);
        assert_eq!(deformation.across(), 0.8);
        assert!((deformation.along() * deformation.across() - 1.0).abs() < 1e-6);
        let cruise = crate::dynamics::LiquidPose {
            speed: 1100.0,
            ..Default::default()
        };
        assert_eq!(toggle_motion_bulge(cruise), 3.5);
    }

    #[test]
    fn released_toggle_holds_the_full_lens_before_fading() {
        let AnimationType::Tween(spec) = toggle_lens_release() else {
            panic!("toggle lens release needs an explicit linger interval");
        };
        assert_eq!(spec.delay_millis, LENS_RELEASE_LINGER_MS);
        assert_eq!(spec.duration_millis, LENS_RELEASE_FADE_MS);
        assert_eq!(spec.easing, Easing::EaseIn);
        // The reference lens stays readable ~750 ms after release
        // (toggle-press sheet still shows it at 883 ms) before the thumb
        // rematerializes.
        assert!((700..=900).contains(&(spec.delay_millis + spec.duration_millis)));
    }
}