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