Skip to main content

max / makeover-timing

28.8 KB · 711 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 //! 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
133 use std::fmt::Write as _;
134 use std::time::Duration;
135
136 use 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)]
144 pub 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
173 impl 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)]
228 pub 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
236 impl 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)]
278 pub 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
304 impl 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]
374 pub 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]
405 pub 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]
421 pub 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]
462 pub 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]
489 pub 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]
505 pub fn timing_css() -> String {
506 in_css_layer(&timing_css_vars())
507 }
508
509 #[cfg(test)]
510 mod 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 }
711