makeover-timing 0.1.2

The time axis of the make-family design system: a duration is named by what it is waiting for, never by a number. Four intents, one duration each, the same on every renderer.
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
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
//! The time axis of the make-family design system.
//!
//! <!-- wiki: makeover-timing -->
//!
//! [`makeover`] resolves colour, `makeover-geometry` resolves distance, and
//! `makeover-layout` names what a thing is without resolving anything. This
//! crate answers the remaining question of the same shape: **how long**.
//!
//! The move is the one the family makes everywhere. A duration is named by
//! what it is waiting for, and the number follows:
//!
//! | Intent | What it waits for | Resolves to |
//! |---|---|---|
//! | [`Intent::Revert`] | a control returning to its resting label after confirming | 1500ms |
//! | [`Intent::Clear`] | a status line emptying itself | 2000ms |
//! | [`Intent::Dismiss`] | a transient notice's lifetime | 3000ms |
//! | [`Intent::Debounce`] | typing settling before the work starts | 150ms |
//!
//! Whether a toast should live 3000ms or 3500ms is unanswerable on its own.
//! Whether a message is a toast or a banner is not. That is the whole argument
//! for the layer, and it is the same one [`Gap`](makeover_geometry::Gap) makes
//! about six pixels.
//!
//! # One duration per intent, on every renderer
//!
//! An intent resolves to exactly one duration everywhere. Not one per renderer,
//! not one per theme.
//!
//! A per-renderer table, resolving the way
//! [`Density`](makeover_geometry::Density) resolves gaps, is the wrong shape.
//! Distance has a renderer axis because a fingertip is coarser than a cursor
//! and a terminal cell is coarser than a pixel: the *surface* differs. Time
//! does not differ that way. A second is a second in a browser, in egui and in
//! a terminal, and the reader waiting it out is the same reader.
//!
//! So a divergence here is a bug report, not an axis.
//!
//! # What is deliberately not a timing intent
//!
//! Three classes are out of scope, and leaving them out is most of what makes
//! the four above coherent.
//!
//! **A race is not an intent.** Waiting 300ms before navigating because the
//! write "should" have landed, or 150ms before closing a dropdown so a
//! mousedown can beat the blur, is a synchronisation bug wearing a duration's
//! clothes. Naming those would launder them into design decisions and give
//! every future one a token to hide behind. They get fixed per screen.
//!
//! **Severity is not an intent either, and it is not a fifth number.** Two
//! renderers already reached for one: MNW gives an error toast 6000ms against
//! an ordinary one's 3000ms, and audiofiles' footer never expires an error at
//! all while an ordinary message goes at 30s. Read together those are not two
//! durations, they are one statement — *a message the user must not miss does
//! not go away on its own* — and `makeover-layout` already has the word for
//! it: such a message is not `Notice::transient`. It is a banner, and a banner
//! has no lifetime. See [`notice_lifetime`].
//!
//! **A poll interval is not an intent.** A retry backoff, a health check, an
//! update check: those are answerable from what they talk to, not from what a
//! reader can follow, and nothing here has an opinion about them.
//!
//! # Motion is a separate axis
//!
//! [`Intent`] says how long a state lasts. [`Motion`] says how long a change
//! takes. CSS itself draws that line — a `setTimeout` against a
//! `transition-duration` — and folding the two together is what makes a
//! "timing scale" unusable: 300ms of fade and 3000ms of toast are not two
//! rungs of one ramp, they are answers to different questions.
//!
//! [`Motion`] has one rung today because the tree has one measured transition.
//! It is an enum rather than a constant so the second one has somewhere to go,
//! and it grows when something is measured, not when a scale looks short.
//!
//! # Cadence is a third question
//!
//! [`Intent`] is how long a state lasts and [`Motion`] is how long a change
//! takes. Both are one-shot: something starts, it ends. [`Cadence`] is how
//! often a repeating mark repeats, which neither of the other two can answer
//! without lying about its own shape.
//!
//! The crate header's exclusion of poll intervals does not cover it, and the
//! difference is the same one that exclusion rests on. A backoff or a health
//! check is answerable from what it talks to. How fast a mark may blink before
//! it reads as an alarm is answerable from the reader, which is what every rung
//! in this crate is answerable from.
//!
//! # Reduced motion
//!
//! The first thing in the family to have a motion-off path, opened here
//! because [`Cadence`] is the first token whose whole existence is movement.
//! A duration that is not animating anything is unaffected by it:
//! [`Intent::Debounce`] is a wait, not a stroke, and reducing motion does not
//! make input settle faster.
//!
//! Two halves, because a browser and an egui window learn about the preference
//! differently. A web surface gets a `prefers-reduced-motion` block in the
//! generated stylesheet and needs no code. Every other renderer asks
//! [`activity_blink`] with the bool its own platform gave it, exactly as it
//! asks [`notice_lifetime`] with the bool the description gave it.
//!
//! # Where the numbers came from
//!
//! Every value below is a count from the tree, not a preference:
//!
//! ```text
//! revert     1500ms   MNW: 6 hand-rolled sites, plus core/clipboard.ts's own default
//! clear      2000ms   MNW: 4 sites
//! dismiss    3000ms   MNW: the toast renderer's lifetime
//! debounce    150ms   audiofiles SEARCH_DEBOUNCE, MNW docs-search.js
//! fade        300ms   MNW: TOAST_FADE_MS, matching the .fade-out transition
//! ```
//!
//! The one contested value is the debounce, where MNW's two category
//! typeaheads sit at 200ms against everything else's 150ms. 150 wins on the
//! count and on the cross-renderer agreement, and the 200s conform.
//!
//! [`Cadence::Activity`] is the one rung not counted off the tree. Wiki
//! `loading-and-progress-standard` rules that an unmeasured wait blinks rather
//! than spins; this is the cadence it blinks at. What it is answerable to is
//! stated on the member.
//!
//! # Consumers
//!
//! Web surfaces bake [`timing_css`] in at build time. Nothing here changes at
//! runtime, so there is no load-time JS step, exactly as with geometry. egui
//! and ratatui surfaces read [`Intent::duration`] instead, which is why this
//! is a crate rather than a stylesheet.
//!
//! [`makeover`]: https://makenot.work/git/max/makeover

#![forbid(unsafe_code)]

use std::fmt::Write as _;
use std::time::Duration;

use makeover_geometry::in_css_layer;

/// A duration named by what it is waiting for.
///
/// Four members, and the set is closed on purpose: each one is a thing a
/// reader is waiting through, and the crate header says what was measured out.
/// Adding a fifth means naming a wait nobody here is already having.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Intent {
    /// How long a control shows that it did something before returning to its
    /// resting label.
    ///
    /// "Copied!" on a button that said "Copy link". Long enough to be read
    /// after the eye has moved back to it, short enough that the control is
    /// honest about its own label again before the next click.
    Revert,
    /// How long a status line holds a message before emptying itself.
    ///
    /// The message is a receipt for something the user just did, so it is read
    /// or not read immediately. Holding it longer means the next glance at that
    /// line reports stale news.
    Clear,
    /// How long a transient notice lives before it starts to leave.
    ///
    /// Excludes the leaving itself, which is [`Motion::Fade`]. A notice the
    /// user must not miss is not transient and gets no lifetime at all; see
    /// [`notice_lifetime`].
    Dismiss,
    /// How long input waits to settle before the work behind it starts.
    ///
    /// A search field that queries on every keystroke, filtered through this.
    /// The number is a claim about typing rather than about the query: below
    /// roughly 100ms an ordinary typist trips it mid-word, and above roughly
    /// 250ms the field feels like it stopped listening.
    Debounce,
}

impl Intent {
    /// The duration in whole milliseconds.
    ///
    /// The primary resolution. [`Self::duration`] and [`Self::css`] are both
    /// spellings of this number, so there is exactly one place it lives.
    #[must_use]
    pub const fn ms(self) -> u32 {
        match self {
            Self::Revert => 1500,
            Self::Clear => 2000,
            Self::Dismiss => 3000,
            Self::Debounce => 150,
        }
    }

    /// The duration as a [`Duration`], for the renderers that are not a
    /// browser.
    #[must_use]
    pub const fn duration(self) -> Duration {
        Duration::from_millis(self.ms() as u64)
    }

    /// The CSS custom property name, without the leading dashes.
    #[must_use]
    pub const fn token(self) -> &'static str {
        match self {
            Self::Revert => "timing-revert",
            Self::Clear => "timing-clear",
            Self::Dismiss => "timing-dismiss",
            Self::Debounce => "timing-debounce",
        }
    }

    /// The CSS value, as a `ms` time.
    ///
    /// Milliseconds rather than seconds at every rung, including the ones that
    /// divide evenly: a stylesheet where some durations read `1.5s` and others
    /// `150ms` cannot be scanned for the odd one out.
    #[must_use]
    pub fn css(self) -> String {
        format!("{}ms", self.ms())
    }

    /// Every intent, in the order they are emitted.
    #[must_use]
    pub const fn all() -> [Self; 4] {
        [Self::Revert, Self::Clear, Self::Dismiss, Self::Debounce]
    }
}

/// How long a change takes, as opposed to how long a state lasts.
///
/// See the crate header for why this is not a fifth [`Intent`]. One rung, and
/// it grows from a measurement rather than from the scale looking short.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Motion {
    /// A thing leaving: opacity to zero, then gone.
    ///
    /// The whole of the departure. A renderer that removes the node itself has
    /// to wait this out first, or it drops the animation mid-stroke.
    Fade,
}

impl Motion {
    /// The duration in whole milliseconds.
    #[must_use]
    pub const fn ms(self) -> u32 {
        match self {
            Self::Fade => 300,
        }
    }

    /// The duration as a [`Duration`].
    #[must_use]
    pub const fn duration(self) -> Duration {
        Duration::from_millis(self.ms() as u64)
    }

    /// The CSS custom property name, without the leading dashes.
    #[must_use]
    pub const fn token(self) -> &'static str {
        match self {
            Self::Fade => "motion-fade",
        }
    }

    /// The CSS value, as a `ms` time.
    #[must_use]
    pub fn css(self) -> String {
        format!("{}ms", self.ms())
    }

    /// Every motion, in the order they are emitted.
    #[must_use]
    pub const fn all() -> [Self; 1] {
        [Self::Fade]
    }
}

/// How often a repeating mark repeats.
///
/// The third question, and see the crate header for why it is neither an
/// [`Intent`] nor a [`Motion`]. One rung, and it grows the same way the others
/// do.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Cadence {
    /// How long the activity mark holds each of its two states.
    ///
    /// The hard-disk light: a small mark saying that something is happening,
    /// on a wait with no countable size. `makeover-layout`'s `Awaiting` is what
    /// says the wait has no size; this is how fast the answer to that blinks.
    ///
    /// **A half-period, not a cycle.** The mark is lit for this long, dark for
    /// this long, and a full cycle is twice it. One number rather than a period
    /// plus a duty cycle, because two numbers are two things three renderers
    /// can disagree about, and a mark that is lit a fifth of the time in a
    /// browser and half the time in a terminal is not one mark.
    ///
    /// 500ms, so a cycle is a second. Not a count off the tree like every other
    /// rung, since nothing has drawn this yet. What it is answerable to is the
    /// eye: fast enough that a glance catches it working, slow enough that it
    /// reads as steady work rather than as an alarm. A blink much under half a
    /// second is a strobe and starts to claim urgency the wait has not earned.
    ///
    /// This is the *fallback*, and on most surfaces it should be the rarer
    /// case. Where the activity is observable the mark follows it, per rule 3
    /// of wiki `loading-and-progress-standard`: an upload blinks per chunk
    /// delivered, and this is for the waits with nothing to watch.
    Activity,
}

impl Cadence {
    /// The duration in whole milliseconds.
    #[must_use]
    pub const fn ms(self) -> u32 {
        match self {
            Self::Activity => 500,
        }
    }

    /// The duration as a [`Duration`].
    #[must_use]
    pub const fn duration(self) -> Duration {
        Duration::from_millis(self.ms() as u64)
    }

    /// One full on-and-off cycle, which is twice the half-period.
    ///
    /// Spelled here rather than doubled at each call site: a renderer driving a
    /// two-state toggle wants [`duration`](Self::duration), and one scheduling
    /// a whole cycle wants this, and neither should be doing the arithmetic.
    #[must_use]
    pub const fn cycle(self) -> Duration {
        Duration::from_millis(self.ms() as u64 * 2)
    }

    /// The CSS custom property name, without the leading dashes.
    #[must_use]
    pub const fn token(self) -> &'static str {
        match self {
            Self::Activity => "cadence-activity",
        }
    }

    /// The CSS value, as a `ms` time.
    #[must_use]
    pub fn css(self) -> String {
        format!("{}ms", self.ms())
    }

    /// Every cadence, in the order they are emitted.
    #[must_use]
    pub const fn all() -> [Self; 1] {
        [Self::Activity]
    }
}

/// How fast the activity mark blinks, given whether the reader has asked for
/// less motion.
///
/// The same seam as [`notice_lifetime`], and it takes a bool for the same
/// reason: the preference is the platform's to report, and taking the answer
/// rather than the platform keeps this crate off everyone's dependency graph.
///
/// ```
/// # use makeover_timing::{Cadence, activity_blink};
/// assert_eq!(activity_blink(false), Some(Cadence::Activity.duration()));
/// assert_eq!(activity_blink(true), None);
/// ```
///
/// `None` is not "the caller decides" and it is not "draw nothing". It means
/// the mark does not blink: it is drawn, lit, and still, for as long as the
/// wait lasts. The reader still learns that something is happening, which is
/// the whole content of the mark; what they are spared is the movement. A
/// renderer that hides the mark instead has removed the information rather than
/// the animation, and reduced motion asks for the second.
///
/// A web surface does not call this. The generated stylesheet carries a
/// `prefers-reduced-motion` block that does the same thing in the cascade,
/// which is why [`reduced_motion_css`] exists.
#[must_use]
pub const fn activity_blink(reduced: bool) -> Option<Duration> {
    if reduced {
        None
    } else {
        Some(Cadence::Activity.duration())
    }
}

/// How long a notice lives, given whether the description calls it transient.
///
/// The seam this crate was built for. `makeover-layout` documents
/// `Notice::Toast` as "transient, stacked, dismisses itself" and
/// `Notice::transient()` returns true for it — the description says a notice
/// goes away on its own and deliberately says nothing about when. This is
/// when, on the renderer's side of the line:
///
/// ```
/// # use makeover_timing::{Intent, notice_lifetime};
/// // notice_lifetime(makeover_layout::Notice::Toast.transient())
/// assert_eq!(notice_lifetime(true), Some(Intent::Dismiss.duration()));
/// assert_eq!(notice_lifetime(false), None);
/// ```
///
/// Taking the bool rather than the enum is what keeps this crate off
/// `makeover-layout`'s dependency graph, and the bool is exactly what the
/// description asserts. A renderer already holds the `Notice`.
///
/// `None` is not "the caller decides". It means the notice has no lifetime:
/// a banner is dismissed by fixing the cause, and an error is a banner. See
/// the crate header on why severity is not a fifth duration.
#[must_use]
pub const fn notice_lifetime(transient: bool) -> Option<Duration> {
    if transient {
        Some(Intent::Dismiss.duration())
    } else {
        None
    }
}

/// Emit the time axis as CSS declarations, no selector.
///
/// [`Intent::Debounce`] is emitted with the rest even though no stylesheet can
/// use it. The point of the layer is that one document holds every duration in
/// the system; a token that lives here for three of the four intents and in a
/// JS constant for the fourth is the drift this crate exists to end, and a
/// script can read the value back off the computed style.
#[must_use]
pub fn timing_css_declarations() -> String {
    let mut out = String::new();
    out.push_str("  /* Time. Named for what is being waited on; the number\n");
    out.push_str("     follows. One duration per intent on every renderer —\n");
    out.push_str("     a divergence here is a bug report, not an axis. */\n");
    for intent in Intent::all() {
        let _ = writeln!(out, "  --{}: {};", intent.token(), intent.css());
    }
    out.push_str("\n  /* Motion: how long a change takes, not how long a state\n");
    out.push_str("     lasts. A separate question, so a separate axis. */\n");
    for motion in Motion::all() {
        let _ = writeln!(out, "  --{}: {};", motion.token(), motion.css());
    }

    out.push_str("\n  /* Cadence: how often a repeating mark repeats. A\n");
    out.push_str("     half-period, so a full cycle is twice it. */\n");
    for cadence in Cadence::all() {
        let _ = writeln!(out, "  --{}: {};", cadence.token(), cadence.css());
    }
    out
}

/// The motion-off block, for a reader who has asked for less of it.
///
/// Everything here that animates resolves to `0ms`. A zero-length transition
/// is a jump to its end state, so a notice stops fading and simply goes when
/// its lifetime is up, with no rule written twice.
///
/// **The blink needs one thing from the rule that consumes it**, and it does
/// not fall out of the zero on its own. A zero-length animation with no
/// `animation-fill-mode` leaves the element in its *base* style, not at its
/// last keyframe. So write the rule with the mark lit in the base style and the
/// keyframes doing the dimming, never the other way round. Then reduced motion
/// stills a lit mark, and the inverted spelling would blank it. See
/// [`activity_blink`] on why removing the mark answers a different request from
/// the one that was made.
///
/// [`Intent`] is untouched. Those are waits rather than strokes, and a reader
/// asking for less motion has not asked for their input to settle sooner or for
/// a notice they are reading to leave early.
#[must_use]
pub fn reduced_motion_css() -> String {
    let mut out = String::new();
    out.push_str("@media (prefers-reduced-motion: reduce) {\n");
    out.push_str("  :root {\n");
    out.push_str("    /* Motion off. The state a stroke was heading for,\n");
    out.push_str("       reached at once; nothing is removed. */\n");
    for motion in Motion::all() {
        let _ = writeln!(out, "    --{}: 0ms;", motion.token());
    }
    for cadence in Cadence::all() {
        let _ = writeln!(out, "    --{}: 0ms;", cadence.token());
    }
    out.push_str("  }\n}\n");
    out
}

/// Emit the whole time axis: a `:root { … }` block, then the motion-off block.
///
/// Mirrors `makeover_geometry::geometry_css_vars`. Like geometry and unlike
/// colour, none of this varies at runtime, so a web consumer bakes it in at
/// build time rather than applying it from JS on load.
///
/// The `prefers-reduced-motion` block rides with the values it overrides rather
/// than being a second thing to remember to include. A consumer that took the
/// vars and not the block would animate at every rung for a reader who asked it
/// not to, and would do it silently.
#[must_use]
pub fn timing_css_vars() -> String {
    format!(
        ":root {{\n{}}}\n\n{}",
        timing_css_declarations(),
        reduced_motion_css()
    )
}

/// The time axis as a stylesheet, inside the family's cascade layer.
///
/// The whole-file entry point, and the one a build script should call.
/// Unlayered declarations outrank every named layer, so generated CSS that
/// stays outside the layer beats the app's own overrides regardless of
/// specificity — which is invisible until the app adopts layers, and then is a
/// puzzle. `makeover_geometry::CSS_LAYER` is the one spelling of the name.
#[must_use]
pub fn timing_css() -> String {
    in_css_layer(&timing_css_vars())
}

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

    #[test]
    fn every_intent_resolves_to_one_number_in_three_spellings() {
        // ms, Duration and CSS are three renderings of one value, so a rung
        // cannot drift between the browser and egui.
        for intent in Intent::all() {
            assert_eq!(intent.duration().as_millis() as u32, intent.ms());
            assert_eq!(intent.css(), format!("{}ms", intent.ms()));
        }
        for motion in Motion::all() {
            assert_eq!(motion.duration().as_millis() as u32, motion.ms());
        }
        for cadence in Cadence::all() {
            assert_eq!(cadence.duration().as_millis() as u32, cadence.ms());
            assert_eq!(cadence.css(), format!("{}ms", cadence.ms()));
        }
    }

    #[test]
    fn the_measured_values_are_the_ones_the_tree_had() {
        // Pinned against the 2026-08-18 count. Changing one of these is a
        // design decision about every consumer at once, which is the point of
        // the crate; a test failure is the argument happening out loud.
        assert_eq!(Intent::Revert.ms(), 1500);
        assert_eq!(Intent::Clear.ms(), 2000);
        assert_eq!(Intent::Dismiss.ms(), 3000);
        assert_eq!(Intent::Debounce.ms(), 150);
        assert_eq!(Motion::Fade.ms(), 300);
        // The one value that is not a count. Pinned all the same: it is the
        // number three renderers agree on, which is the whole reason it is
        // here rather than in each of them.
        assert_eq!(Cadence::Activity.ms(), 500);
    }

    #[test]
    fn a_notice_leaves_after_its_lifetime_and_its_fade() {
        // The two numbers the toast class needs, and the reason they are on
        // different axes: a renderer that removes the node at Dismiss drops
        // the animation, and one that waits Dismiss + Fade is correct.
        assert_eq!(notice_lifetime(true), Some(Duration::from_secs(3)));
        assert!(Motion::Fade.duration() < Intent::Dismiss.duration());
    }

    #[test]
    fn a_notice_that_is_not_transient_has_no_lifetime() {
        // Not "the caller decides" — a banner is dismissed by fixing the cause.
        // This is where an error toast's second number went.
        assert_eq!(notice_lifetime(false), None);
    }

    #[test]
    fn debounce_is_the_shortest_wait_and_a_notice_the_longest() {
        // The ordering is the sanity check on the set: input settling is the
        // one wait a user is inside rather than watching, so it is the only
        // sub-second rung, and nothing may quietly grow past a notice.
        assert!(
            Intent::all()
                .iter()
                .all(|i| i.ms() >= Intent::Debounce.ms())
        );
        assert!(Intent::all().iter().all(|i| i.ms() <= Intent::Dismiss.ms()));
    }

    #[test]
    fn no_intent_is_long_enough_to_be_a_poll_interval() {
        // A ceiling with an argument behind it: every rung here is a wait a
        // reader sits through, and past a few seconds that stops being true.
        // A backoff or a health check answers to what it talks to, not here.
        assert!(Intent::all().iter().all(|i| i.ms() <= 5_000));
    }

    #[test]
    fn the_layer_is_emitted_inside_the_family_layer() {
        let css = timing_css();
        assert!(css.starts_with("@layer makeover {\n"));
        assert!(css.contains("    :root {"));
        assert!(css.trim_end().ends_with('}'));
    }

    #[test]
    fn every_token_reaches_the_stylesheet_exactly_once() {
        let css = timing_css();
        for intent in Intent::all() {
            let decl = format!("--{}: {}", intent.token(), intent.css());
            assert_eq!(css.matches(&decl).count(), 1, "{}", intent.token());
        }
        for motion in Motion::all() {
            let decl = format!("--{}: {}", motion.token(), motion.css());
            assert_eq!(css.matches(&decl).count(), 1, "{}", motion.token());
        }
        for cadence in Cadence::all() {
            let decl = format!("--{}: {}", cadence.token(), cadence.css());
            assert_eq!(css.matches(&decl).count(), 1, "{}", cadence.token());
        }
    }

    #[test]
    fn tokens_are_prefixed_by_their_axis() {
        // `--timing-*` for a state's length, `--motion-*` for a change's. A
        // reader scanning the sheet can tell which question a var answers.
        assert!(
            Intent::all()
                .iter()
                .all(|i| i.token().starts_with("timing-"))
        );
        assert!(
            Motion::all()
                .iter()
                .all(|m| m.token().starts_with("motion-"))
        );
        assert!(
            Cadence::all()
                .iter()
                .all(|c| c.token().starts_with("cadence-"))
        );
    }

    #[test]
    fn the_blink_is_a_half_period_and_the_cycle_is_twice_it() {
        // The one arithmetic a renderer must not be doing itself. A mark lit
        // for 500ms and dark for 500ms is a one-second cycle, and a renderer
        // scheduling the cycle where it meant the half draws at half speed.
        assert_eq!(Cadence::Activity.cycle(), Cadence::Activity.duration() * 2);
        assert_eq!(Cadence::Activity.cycle(), Duration::from_secs(1));
    }

    #[test]
    fn the_blink_is_slower_than_a_stroke_and_faster_than_a_notice() {
        // Where it sits between the existing axes is the sanity check on the
        // number. Faster than Fade and it is a strobe; slower than the shortest
        // thing a reader sits through and it reads as stalled rather than busy.
        assert!(Cadence::Activity.duration() > Motion::Fade.duration());
        assert!(Cadence::Activity.duration() < Intent::Revert.duration());
    }

    #[test]
    fn reduced_motion_stills_the_mark_rather_than_removing_it() {
        // None means lit and static, not absent. The distinction is the whole
        // of what reduced motion asks for: less movement, not less information.
        assert_eq!(activity_blink(false), Some(Cadence::Activity.duration()));
        assert_eq!(activity_blink(true), None);
    }

    #[test]
    fn reduced_motion_leaves_the_waits_alone() {
        // Only what animates is zeroed. A debounce is a wait, and a reader
        // asking for less motion has not asked their input to settle sooner.
        let css = reduced_motion_css();
        for intent in Intent::all() {
            assert!(!css.contains(intent.token()), "{}", intent.token());
        }
        for motion in Motion::all() {
            assert!(css.contains(&format!("--{}: 0ms;", motion.token())));
        }
        for cadence in Cadence::all() {
            assert!(css.contains(&format!("--{}: 0ms;", cadence.token())));
        }
    }

    #[test]
    fn the_motion_off_block_ships_with_the_values_it_overrides() {
        // A consumer taking the vars and not the block animates at every rung
        // for a reader who asked it not to, and does it silently. So the
        // whole-file entry point carries both, inside the family layer.
        let css = timing_css();
        assert!(css.contains("@media (prefers-reduced-motion: reduce)"));
        let vars = css
            .find(&format!(
                "--{}: {}",
                Cadence::Activity.token(),
                Cadence::Activity.css()
            ))
            .expect("the value");
        let off = css.find("prefers-reduced-motion").expect("the block");
        assert!(
            vars < off,
            "the override has to come after what it overrides"
        );
    }

    #[test]
    fn no_two_rungs_share_a_name_or_a_value() {
        // A duplicate name silently overwrites in the cascade; a duplicate
        // value is two names for one thing, which is a distinction nobody can
        // choose between.
        let mut tokens: Vec<&str> = Intent::all().iter().map(|i| i.token()).collect();
        tokens.extend(Motion::all().iter().map(|m| m.token()));
        tokens.extend(Cadence::all().iter().map(|c| c.token()));
        let mut sorted = tokens.clone();
        sorted.sort_unstable();
        sorted.dedup();
        assert_eq!(sorted.len(), tokens.len(), "{tokens:?}");

        let mut values: Vec<u32> = Intent::all().iter().map(|i| i.ms()).collect();
        values.sort_unstable();
        values.dedup();
        assert_eq!(values.len(), Intent::all().len(), "two intents, one number");
    }
}