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