Skip to main content

makeover_timing/
lib.rs

1//! The time axis of the make-family design system.
2//!
3//! <!-- wiki: makeover-timing -->
4//!
5//! [`makeover`] resolves colour, `makeover-geometry` resolves distance, and
6//! `makeover-layout` names what a thing is without resolving anything. This
7//! crate answers the remaining question of the same shape: **how long**.
8//!
9//! The move is the one the family makes everywhere. A duration is named by
10//! what it is waiting for, and the number follows:
11//!
12//! | Moment | What it waits for | Resolves to |
13//! |---|---|---|
14//! | [`Moment::Revert`] | a control returning to its resting label after confirming | 1500ms |
15//! | [`Moment::Clear`] | a status line emptying itself | 2000ms |
16//! | [`Moment::Dismiss`] | a transient notice's lifetime | 3000ms |
17//! | [`Moment::Debounce`] | typing settling before the work starts | 150ms |
18//!
19//! Whether a toast should live 3000ms or 3500ms is unanswerable on its own.
20//! Whether a message is a toast or a banner is not. That is the whole argument
21//! for the layer, and it is the same one [`Gap`](makeover_geometry::Gap) makes
22//! about six pixels.
23//!
24//! # One duration per moment, on every renderer
25//!
26//! A moment resolves to exactly one duration everywhere, whatever the renderer
27//! and whatever the theme.
28//!
29//! A per-renderer table, resolving the way
30//! [`Density`](makeover_geometry::Density) resolves gaps, is the wrong shape.
31//! Distance has a renderer axis because a fingertip is coarser than a cursor
32//! and a terminal cell is coarser than a pixel: the *surface* differs. Time
33//! does not differ that way. A second is a second in a browser, in egui and in
34//! a terminal, and the reader waiting it out is the same reader.
35//!
36//! So a divergence here is a bug report.
37//!
38//! # What is deliberately not a moment
39//!
40//! Three classes are out of scope, and leaving them out is most of what makes
41//! the four above coherent.
42//!
43//! **A race is not a moment.** Waiting 300ms before navigating because the
44//! write "should" have landed, or 150ms before closing a dropdown so a
45//! mousedown can beat the blur, is a synchronisation bug wearing a duration's
46//! clothes. Naming those would launder them into design decisions and give
47//! every future one a token to hide behind. They get fixed per screen.
48//!
49//! **Severity is not a moment either, and it is not a fifth number.** A longer
50//! lifetime for an error toast is one statement in disguise: *a message the
51//! user must not miss does not go away on its own*. `makeover-layout` already
52//! has the word for it: such a message is not `Notice::transient`. It is a
53//! banner, and a banner has no lifetime. See [`notice_lifetime`].
54//!
55//! **A poll interval is not a moment.** A retry backoff, a health check, an
56//! update check: those are answerable from what they talk to, not from what a
57//! reader can follow, and nothing here has an opinion about them.
58//!
59//! # Motion is a separate axis
60//!
61//! [`Moment`] says how long a state lasts. [`Motion`] says how long a change
62//! takes. CSS itself draws that line, a `setTimeout` against a
63//! `transition-duration`, and folding the two together makes a "timing scale"
64//! unusable: 300ms of fade and 3000ms of toast answer different questions.
65//!
66//! [`Motion`] is an enum rather than a constant so a second measured
67//! transition has somewhere to go. It grows when something is measured.
68//!
69//! # Cadence is a third question
70//!
71//! [`Moment`] is how long a state lasts and [`Motion`] is how long a change
72//! takes. Both are one-shot: something starts, it ends. [`Cadence`] is how
73//! often a repeating mark repeats, which neither of the other two can answer
74//! without lying about its own shape.
75//!
76//! The crate header's exclusion of poll intervals does not cover it, and the
77//! difference is the same one that exclusion rests on. A backoff or a health
78//! check is answerable from what it talks to. How fast a mark may blink before
79//! it reads as an alarm is answerable from the reader, which is what every rung
80//! in this crate is answerable from.
81//!
82//! # Reduced motion
83//!
84//! Anything whose whole existence is movement has a motion-off path, and
85//! [`Cadence`] is that. A duration that is not animating anything is
86//! unaffected: [`Moment::Debounce`] is a wait, and reducing motion does not
87//! make input settle faster.
88//!
89//! Two halves, because a browser and an egui window learn about the preference
90//! differently. A web surface gets a `prefers-reduced-motion` block in the
91//! generated stylesheet and needs no code. Every other renderer asks
92//! [`activity_blink`] with the [`MotionPreference`] its platform reported, as
93//! it asks [`notice_lifetime`] with the [`Persistence`] the description gave.
94//!
95//! # The numbers
96//!
97//! Each value is pinned by a test. Changing one is a design decision about
98//! every consumer at once, so the test failing is that argument happening out
99//! loud. What each is answerable to is stated on the member.
100//!
101//! # Consumers
102//!
103//! Web surfaces bake [`timing_css`] in at build time. Nothing here changes at
104//! runtime, so there is no load-time JS step, exactly as with geometry. egui
105//! and ratatui surfaces read [`Moment::duration`] instead, which is why this
106//! is a crate rather than a stylesheet.
107//!
108//! [`makeover`]: https://makenot.work/git/max/makeover
109
110#![forbid(unsafe_code)]
111
112use std::fmt::Write as _;
113use std::time::Duration;
114
115use makeover_geometry::in_css_layer;
116
117/// A duration named by what it is waiting for.
118///
119/// Each member is a thing a reader is waiting through, and the crate header
120/// says what is deliberately left out. Adding one means naming a wait a
121/// renderer is already having.
122///
123/// Named `Moment` rather than `Intent` so it cannot be confused with
124/// `makeover_layout::Intent`, which names what a thing is rather than how long
125/// a state lasts.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
127pub enum Moment {
128    /// How long a control shows that it did something before returning to its
129    /// resting label.
130    ///
131    /// "Copied!" on a button that said "Copy link". Long enough to be read
132    /// after the eye has moved back to it, short enough that the control is
133    /// honest about its own label again before the next click.
134    Revert,
135    /// How long a status line holds a message before emptying itself.
136    ///
137    /// The message is a receipt for something the user just did, so it is read
138    /// or not read immediately. Holding it longer means the next glance at that
139    /// line reports stale news.
140    Clear,
141    /// How long a transient notice lives before it starts to leave.
142    ///
143    /// Excludes the leaving itself, which is [`Motion::Fade`]. A notice the
144    /// user must not miss is not transient and gets no lifetime at all; see
145    /// [`notice_lifetime`].
146    Dismiss,
147    /// How long input waits to settle before the work behind it starts.
148    ///
149    /// A search field that queries on every keystroke, filtered through this.
150    /// The number is a claim about typing rather than about the query: below
151    /// roughly 100ms an ordinary typist trips it mid-word, and above roughly
152    /// 250ms the field feels like it stopped listening.
153    Debounce,
154}
155
156impl Moment {
157    /// The duration in whole milliseconds.
158    ///
159    /// The primary resolution. [`Self::duration`] and [`Self::css`] are both
160    /// spellings of this number, so there is exactly one place it lives.
161    #[must_use]
162    pub const fn ms(self) -> u32 {
163        match self {
164            Self::Revert => 1500,
165            Self::Clear => 2000,
166            Self::Dismiss => 3000,
167            Self::Debounce => 150,
168        }
169    }
170
171    /// The duration as a [`Duration`], for the renderers that are not a
172    /// browser.
173    #[must_use]
174    pub const fn duration(self) -> Duration {
175        Duration::from_millis(self.ms() as u64)
176    }
177
178    /// The CSS custom property name, without the leading dashes.
179    #[must_use]
180    pub const fn token(self) -> &'static str {
181        match self {
182            Self::Revert => "timing-revert",
183            Self::Clear => "timing-clear",
184            Self::Dismiss => "timing-dismiss",
185            Self::Debounce => "timing-debounce",
186        }
187    }
188
189    /// The CSS value, as a `ms` time.
190    ///
191    /// Milliseconds rather than seconds at every rung, including the ones that
192    /// divide evenly: a stylesheet where some durations read `1.5s` and others
193    /// `150ms` cannot be scanned for the odd one out.
194    #[must_use]
195    pub fn css(self) -> String {
196        format!("{}ms", self.ms())
197    }
198
199    /// Every moment, in the order they are emitted.
200    ///
201    /// A slice rather than an array, so adding a member changes a length and
202    /// not a type.
203    #[must_use]
204    pub const fn all() -> &'static [Self] {
205        &[Self::Revert, Self::Clear, Self::Dismiss, Self::Debounce]
206    }
207}
208
209/// How long a change takes, as opposed to how long a state lasts.
210///
211/// See the crate header for why this is not a [`Moment`]. It grows from a
212/// measurement rather than from the scale looking short.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
214pub enum Motion {
215    /// A thing leaving: opacity to zero, then gone.
216    ///
217    /// The whole of the departure. A renderer that removes the node itself has
218    /// to wait this out first, or it drops the animation mid-stroke.
219    Fade,
220    /// A state answering a pointer or a press: a hover tone, a pressed bevel,
221    /// a disclosure turning. Measured from the 0.1s and 0.2s hover literals
222    /// GoingsOn, the server and multithreaded each wrote, and the 120ms quasi
223    /// wrote for its disclosure; short enough to read as the control's own
224    /// response rather than as an animation.
225    Quick,
226}
227
228impl Motion {
229    /// The duration in whole milliseconds.
230    #[must_use]
231    pub const fn ms(self) -> u32 {
232        match self {
233            Self::Fade => 300,
234            Self::Quick => 120,
235        }
236    }
237
238    /// The duration as a [`Duration`].
239    #[must_use]
240    pub const fn duration(self) -> Duration {
241        Duration::from_millis(self.ms() as u64)
242    }
243
244    /// The CSS custom property name, without the leading dashes.
245    #[must_use]
246    pub const fn token(self) -> &'static str {
247        match self {
248            Self::Fade => "motion-fade",
249            Self::Quick => "motion-quick",
250        }
251    }
252
253    /// The CSS value, as a `ms` time.
254    #[must_use]
255    pub fn css(self) -> String {
256        format!("{}ms", self.ms())
257    }
258
259    /// Every motion, in the order they are emitted.
260    #[must_use]
261    pub const fn all() -> &'static [Self] {
262        &[Self::Fade, Self::Quick]
263    }
264}
265
266/// How often a repeating mark repeats.
267///
268/// The third question, and see the crate header for why it is neither a
269/// [`Moment`] nor a [`Motion`]. It grows the same way the others do.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
271pub enum Cadence {
272    /// How long the activity mark holds each of its two states.
273    ///
274    /// The hard-disk light: a small mark saying that something is happening,
275    /// on a wait with no countable size. `makeover-layout`'s `Awaiting` is what
276    /// says the wait has no size; this is how fast the answer to that blinks.
277    ///
278    /// **A half-period, not a cycle.** The mark is lit for this long, dark for
279    /// this long, and a full cycle is twice it. One number rather than a period
280    /// plus a duty cycle, because two numbers are two things three renderers
281    /// can disagree about, and a mark that is lit a fifth of the time in a
282    /// browser and half the time in a terminal is not one mark.
283    ///
284    /// 500ms, so a cycle is a second. What it is answerable to is the eye: fast
285    /// enough that a glance catches it working, slow enough that it
286    /// reads as steady work rather than as an alarm. A blink much under half a
287    /// second is a strobe and starts to claim urgency the wait has not earned.
288    ///
289    /// This is the *fallback*, and on most surfaces it should be the rarer
290    /// case. Where the activity is observable the mark follows it, per rule 3
291    /// of wiki `loading-and-progress-standard`: an upload blinks per chunk
292    /// delivered, and this is for the waits with nothing to watch.
293    Activity,
294}
295
296impl Cadence {
297    /// The duration in whole milliseconds.
298    #[must_use]
299    pub const fn ms(self) -> u32 {
300        match self {
301            Self::Activity => 500,
302        }
303    }
304
305    /// The duration as a [`Duration`].
306    #[must_use]
307    pub const fn duration(self) -> Duration {
308        Duration::from_millis(self.ms() as u64)
309    }
310
311    /// One full on-and-off cycle, which is twice the half-period.
312    ///
313    /// Spelled here rather than doubled at each call site: a renderer driving a
314    /// two-state toggle wants [`duration`](Self::duration), and one scheduling
315    /// a whole cycle wants this, and neither should be doing the arithmetic.
316    #[must_use]
317    pub const fn cycle(self) -> Duration {
318        Duration::from_millis(self.ms() as u64 * 2)
319    }
320
321    /// The CSS custom property name, without the leading dashes.
322    #[must_use]
323    pub const fn token(self) -> &'static str {
324        match self {
325            Self::Activity => "cadence-activity",
326        }
327    }
328
329    /// The CSS value, as a `ms` time.
330    #[must_use]
331    pub fn css(self) -> String {
332        format!("{}ms", self.ms())
333    }
334
335    /// Every cadence, in the order they are emitted.
336    #[must_use]
337    pub const fn all() -> &'static [Self] {
338        &[Self::Activity]
339    }
340}
341
342/// Whether the reader has asked for less motion, as their platform reported it.
343///
344/// Exhaustive on purpose: it is a two-state answer the caller supplies, and a
345/// third state would be a question this crate has not been asked. An enum
346/// rather than a bool so a call site reads as what it means.
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
348pub enum MotionPreference {
349    /// No preference stated, so things move.
350    #[default]
351    Full,
352    /// The reader asked for less movement.
353    Reduced,
354}
355
356/// Whether a notice goes away on its own.
357///
358/// What `makeover_layout::Notice::transient` answers, taken as its answer
359/// rather than as the `Notice`, which keeps this crate off `makeover-layout`'s
360/// dependency graph. Exhaustive for [`MotionPreference`]'s reason.
361#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
362pub enum Persistence {
363    /// It dismisses itself, after [`Moment::Dismiss`].
364    Transient,
365    /// It stays until its cause is fixed, and has no lifetime.
366    Standing,
367}
368
369/// How fast the activity mark blinks, given whether the reader has asked for
370/// less motion.
371///
372/// The same seam as [`notice_lifetime`]: the preference is the platform's to
373/// report, and taking the answer rather than the platform keeps this crate off
374/// everyone's dependency graph.
375///
376/// ```
377/// # use makeover_timing::{Cadence, MotionPreference, activity_blink};
378/// assert_eq!(
379///     activity_blink(MotionPreference::Full),
380///     Some(Cadence::Activity.duration())
381/// );
382/// assert_eq!(activity_blink(MotionPreference::Reduced), None);
383/// ```
384///
385/// `None` is not "the caller decides" and it is not "draw nothing". It means
386/// the mark does not blink: it is drawn, lit, and still, for as long as the
387/// wait lasts. The reader still learns that something is happening, which is
388/// the whole content of the mark; what they are spared is the movement. A
389/// renderer that hides the mark instead has removed the information rather than
390/// the animation, and reduced motion asks for the second.
391///
392/// A web surface does not call this. The generated stylesheet carries a
393/// `prefers-reduced-motion` block that does the same thing in the cascade,
394/// which is why [`reduced_motion_css`] exists.
395#[must_use]
396pub const fn activity_blink(preference: MotionPreference) -> Option<Duration> {
397    match preference {
398        MotionPreference::Full => Some(Cadence::Activity.duration()),
399        MotionPreference::Reduced => None,
400    }
401}
402
403/// How long a notice lives, given whether it goes away on its own.
404///
405/// `makeover-layout` documents `Notice::Toast` as "transient, stacked,
406/// dismisses itself", and the description deliberately says nothing about
407/// when. This is when, on the renderer's side of the line:
408///
409/// ```
410/// # use makeover_timing::{Moment, Persistence, notice_lifetime};
411/// assert_eq!(
412///     notice_lifetime(Persistence::Transient),
413///     Some(Moment::Dismiss.duration())
414/// );
415/// assert_eq!(notice_lifetime(Persistence::Standing), None);
416/// ```
417///
418/// `None` is not "the caller decides". It means the notice has no lifetime:
419/// a banner is dismissed by fixing the cause, and an error is a banner. See
420/// the crate header on why severity is not a fifth duration.
421#[must_use]
422pub const fn notice_lifetime(persistence: Persistence) -> Option<Duration> {
423    match persistence {
424        Persistence::Transient => Some(Moment::Dismiss.duration()),
425        Persistence::Standing => None,
426    }
427}
428
429/// Emit the time axis as CSS declarations, no selector.
430///
431/// [`Moment::Debounce`] is emitted with the rest even though no stylesheet can
432/// use it. One document holds every duration in the system, so a token cannot
433/// live here for some moments and in a JS constant for another, and a script
434/// can read the value back off the computed style.
435#[must_use]
436pub fn timing_css_declarations() -> String {
437    let mut out = String::new();
438    out.push_str("  /* Time. Named for what is being waited on; the number\n");
439    out.push_str("     follows. One duration per moment on every renderer. */\n");
440    for moment in Moment::all() {
441        let _ = writeln!(out, "  --{}: {};", moment.token(), moment.css());
442    }
443    out.push_str("\n  /* Motion: how long a change takes, not how long a state\n");
444    out.push_str("     lasts. A separate question, so a separate axis. */\n");
445    for motion in Motion::all() {
446        let _ = writeln!(out, "  --{}: {};", motion.token(), motion.css());
447    }
448
449    out.push_str("\n  /* Cadence: how often a repeating mark repeats. A\n");
450    out.push_str("     half-period, so a full cycle is twice it. */\n");
451    for cadence in Cadence::all() {
452        let _ = writeln!(out, "  --{}: {};", cadence.token(), cadence.css());
453    }
454    out
455}
456
457/// The motion-off block, for a reader who has asked for less of it.
458///
459/// Everything here that animates resolves to `0ms`. A zero-length transition
460/// is a jump to its end state, so a notice stops fading and simply goes when
461/// its lifetime is up, with no rule written twice.
462///
463/// **The blink needs one thing from the rule that consumes it**, and it does
464/// not fall out of the zero on its own. A zero-length animation with no
465/// `animation-fill-mode` leaves the element in its *base* style, not at its
466/// last keyframe. So write the rule with the mark lit in the base style and the
467/// keyframes doing the dimming, never the other way round. Then reduced motion
468/// stills a lit mark, and the inverted spelling would blank it. See
469/// [`activity_blink`] on why removing the mark answers a different request from
470/// the one that was made.
471///
472/// [`Moment`] is untouched. Those are waits rather than strokes, and a reader
473/// asking for less motion has not asked for their input to settle sooner or for
474/// a notice they are reading to leave early.
475#[must_use]
476pub fn reduced_motion_css() -> String {
477    let mut out = String::new();
478    out.push_str("@media (prefers-reduced-motion: reduce) {\n");
479    out.push_str("  :root {\n");
480    out.push_str("    /* Motion off. The state a stroke was heading for,\n");
481    out.push_str("       reached at once; nothing is removed. */\n");
482    for motion in Motion::all() {
483        let _ = writeln!(out, "    --{}: 0ms;", motion.token());
484    }
485    for cadence in Cadence::all() {
486        let _ = writeln!(out, "    --{}: 0ms;", cadence.token());
487    }
488    out.push_str("  }\n}\n");
489    out
490}
491
492/// Emit the whole time axis: a `:root { ... }` block, then the motion-off block.
493///
494/// Mirrors `makeover_geometry::geometry_css_vars`. Like geometry and unlike
495/// colour, none of this varies at runtime, so a web consumer bakes it in at
496/// build time rather than applying it from JS on load.
497///
498/// The `prefers-reduced-motion` block rides with the values it overrides rather
499/// than being a second thing to remember to include. A consumer that took the
500/// vars and not the block would animate at every rung for a reader who asked it
501/// not to, and would do it silently.
502#[must_use]
503pub fn timing_css_vars() -> String {
504    format!(
505        ":root {{\n{}}}\n\n{}",
506        timing_css_declarations(),
507        reduced_motion_css()
508    )
509}
510
511/// The time axis as a stylesheet, inside the family's cascade layer.
512///
513/// The whole-file entry point, and the one a build script should call.
514/// Unlayered declarations outrank every named layer, so generated CSS that
515/// stays outside the layer beats the app's own overrides regardless of
516/// specificity, which is invisible until the app adopts layers. `makeover_geometry::CSS_LAYER` is the one spelling of the name.
517#[must_use]
518pub fn timing_css() -> String {
519    in_css_layer(&timing_css_vars())
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn every_intent_resolves_to_one_number_in_three_spellings() {
528        // ms, Duration and CSS are three renderings of one value, so a rung
529        // cannot drift between the browser and egui.
530        for moment in Moment::all() {
531            assert_eq!(moment.duration().as_millis() as u32, moment.ms());
532            assert_eq!(moment.css(), format!("{}ms", moment.ms()));
533        }
534        for motion in Motion::all() {
535            assert_eq!(motion.duration().as_millis() as u32, motion.ms());
536        }
537        for cadence in Cadence::all() {
538            assert_eq!(cadence.duration().as_millis() as u32, cadence.ms());
539            assert_eq!(cadence.css(), format!("{}ms", cadence.ms()));
540        }
541    }
542
543    #[test]
544    fn the_measured_values_are_the_ones_the_tree_had() {
545        // Changing one of these is a design decision about every consumer at
546        // once, which is the point of the crate; a test failure is the argument
547        // happening out loud.
548        assert_eq!(Moment::Revert.ms(), 1500);
549        assert_eq!(Moment::Clear.ms(), 2000);
550        assert_eq!(Moment::Dismiss.ms(), 3000);
551        assert_eq!(Moment::Debounce.ms(), 150);
552        assert_eq!(Motion::Fade.ms(), 300);
553        // The one value that is not a count. Pinned all the same: it is the
554        // number three renderers agree on, which is the whole reason it is
555        // here rather than in each of them.
556        assert_eq!(Cadence::Activity.ms(), 500);
557    }
558
559    #[test]
560    fn a_notice_leaves_after_its_lifetime_and_its_fade() {
561        // The two numbers the toast class needs, and the reason they are on
562        // different axes: a renderer that removes the node at Dismiss drops
563        // the animation, and one that waits Dismiss + Fade is correct.
564        assert_eq!(
565            notice_lifetime(Persistence::Transient),
566            Some(Duration::from_secs(3))
567        );
568        assert!(Motion::Fade.duration() < Moment::Dismiss.duration());
569    }
570
571    #[test]
572    fn a_notice_that_is_not_transient_has_no_lifetime() {
573        // Not "the caller decides": a banner is dismissed by fixing the cause.
574        assert_eq!(notice_lifetime(Persistence::Standing), None);
575    }
576
577    #[test]
578    fn debounce_is_the_shortest_wait_and_a_notice_the_longest() {
579        // The ordering is the sanity check on the set: input settling is the
580        // one wait a user is inside rather than watching, so it is the only
581        // sub-second rung, and nothing may quietly grow past a notice.
582        assert!(
583            Moment::all()
584                .iter()
585                .all(|i| i.ms() >= Moment::Debounce.ms())
586        );
587        assert!(Moment::all().iter().all(|i| i.ms() <= Moment::Dismiss.ms()));
588    }
589
590    #[test]
591    fn no_intent_is_long_enough_to_be_a_poll_interval() {
592        // A ceiling with an argument behind it: every rung here is a wait a
593        // reader sits through, and past a few seconds that stops being true.
594        // A backoff or a health check answers to what it talks to, not here.
595        assert!(Moment::all().iter().all(|i| i.ms() <= 5_000));
596    }
597
598    #[test]
599    fn the_layer_is_emitted_inside_the_family_layer() {
600        let css = timing_css();
601        assert!(css.starts_with("@layer makeover {\n"));
602        assert!(css.contains("    :root {"));
603        assert!(css.trim_end().ends_with('}'));
604    }
605
606    #[test]
607    fn every_token_reaches_the_stylesheet_exactly_once() {
608        let css = timing_css();
609        for moment in Moment::all() {
610            let decl = format!("--{}: {}", moment.token(), moment.css());
611            assert_eq!(css.matches(&decl).count(), 1, "{}", moment.token());
612        }
613        for motion in Motion::all() {
614            let decl = format!("--{}: {}", motion.token(), motion.css());
615            assert_eq!(css.matches(&decl).count(), 1, "{}", motion.token());
616        }
617        for cadence in Cadence::all() {
618            let decl = format!("--{}: {}", cadence.token(), cadence.css());
619            assert_eq!(css.matches(&decl).count(), 1, "{}", cadence.token());
620        }
621    }
622
623    #[test]
624    fn tokens_are_prefixed_by_their_axis() {
625        // `--timing-*` for a state's length, `--motion-*` for a change's. A
626        // reader scanning the sheet can tell which question a var answers.
627        assert!(
628            Moment::all()
629                .iter()
630                .all(|i| i.token().starts_with("timing-"))
631        );
632        assert!(
633            Motion::all()
634                .iter()
635                .all(|m| m.token().starts_with("motion-"))
636        );
637        assert!(
638            Cadence::all()
639                .iter()
640                .all(|c| c.token().starts_with("cadence-"))
641        );
642    }
643
644    #[test]
645    fn the_blink_is_a_half_period_and_the_cycle_is_twice_it() {
646        // The one arithmetic a renderer must not be doing itself. A mark lit
647        // for 500ms and dark for 500ms is a one-second cycle, and a renderer
648        // scheduling the cycle where it meant the half draws at half speed.
649        assert_eq!(Cadence::Activity.cycle(), Cadence::Activity.duration() * 2);
650        assert_eq!(Cadence::Activity.cycle(), Duration::from_secs(1));
651    }
652
653    #[test]
654    fn the_blink_is_slower_than_a_stroke_and_faster_than_a_notice() {
655        // Where it sits between the existing axes is the sanity check on the
656        // number. Faster than Fade and it is a strobe; slower than the shortest
657        // thing a reader sits through and it reads as stalled rather than busy.
658        assert!(Cadence::Activity.duration() > Motion::Fade.duration());
659        assert!(Cadence::Activity.duration() < Moment::Revert.duration());
660    }
661
662    #[test]
663    fn reduced_motion_stills_the_mark_rather_than_removing_it() {
664        // None means lit and static, not absent. The distinction is the whole
665        // of what reduced motion asks for: less movement, not less information.
666        assert_eq!(
667            activity_blink(MotionPreference::Full),
668            Some(Cadence::Activity.duration())
669        );
670        assert_eq!(activity_blink(MotionPreference::Reduced), None);
671    }
672
673    #[test]
674    fn a_quick_answer_is_shorter_than_a_fade_and_stills_under_reduced_motion() {
675        assert!(Motion::Quick.duration() < Motion::Fade.duration());
676        assert!(reduced_motion_css().contains("--motion-quick: 0ms;"));
677    }
678
679    #[test]
680    fn reduced_motion_leaves_the_waits_alone() {
681        // Only what animates is zeroed. A debounce is a wait, and a reader
682        // asking for less motion has not asked their input to settle sooner.
683        let css = reduced_motion_css();
684        for moment in Moment::all() {
685            assert!(!css.contains(moment.token()), "{}", moment.token());
686        }
687        for motion in Motion::all() {
688            assert!(css.contains(&format!("--{}: 0ms;", motion.token())));
689        }
690        for cadence in Cadence::all() {
691            assert!(css.contains(&format!("--{}: 0ms;", cadence.token())));
692        }
693    }
694
695    #[test]
696    fn the_motion_off_block_ships_with_the_values_it_overrides() {
697        // A consumer taking the vars and not the block animates at every rung
698        // for a reader who asked it not to, and does it silently. So the
699        // whole-file entry point carries both, inside the family layer.
700        let css = timing_css();
701        assert!(css.contains("@media (prefers-reduced-motion: reduce)"));
702        let vars = css
703            .find(&format!(
704                "--{}: {}",
705                Cadence::Activity.token(),
706                Cadence::Activity.css()
707            ))
708            .expect("the value");
709        let off = css.find("prefers-reduced-motion").expect("the block");
710        assert!(
711            vars < off,
712            "the override has to come after what it overrides"
713        );
714    }
715
716    #[test]
717    fn no_two_rungs_share_a_name_or_a_value() {
718        // A duplicate name silently overwrites in the cascade; a duplicate
719        // value is two names for one thing, which is a distinction nobody can
720        // choose between.
721        let mut tokens: Vec<&str> = Moment::all().iter().map(|i| i.token()).collect();
722        tokens.extend(Motion::all().iter().map(|m| m.token()));
723        tokens.extend(Cadence::all().iter().map(|c| c.token()));
724        let mut sorted = tokens.clone();
725        sorted.sort_unstable();
726        sorted.dedup();
727        assert_eq!(sorted.len(), tokens.len(), "{tokens:?}");
728
729        let mut values: Vec<u32> = Moment::all().iter().map(|i| i.ms()).collect();
730        values.sort_unstable();
731        values.dedup();
732        assert_eq!(values.len(), Moment::all().len(), "two intents, one number");
733    }
734}