Skip to main content

max / makeover-timing

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