Skip to main content

max / makeover-timing

A cadence axis, and the family's first motion-off path The activity mark ruled in wiki `loading-and-progress-standard` needs one number three renderers agree on, or each picks its own heartbeat and the same wait blinks differently in a browser, an egui window and a terminal. `Cadence` rather than a fifth `Intent` or a second `Motion`. Both of those are one-shot: something starts, it ends. This is how often a repeating mark repeats, which neither can answer without lying about its own shape. The crate header's exclusion of poll intervals does not cover it either, and for the reason that exclusion rests on: a backoff answers to what it talks to, and how fast a mark may blink before it reads as an alarm answers to the reader, which is what every rung here answers to. A half-period, not a cycle. The mark is lit for 500ms and dark for 500ms, so one number instead of a period plus a duty cycle: two numbers are two things three renderers can disagree about, and a mark lit a fifth of the time in a browser and half the time in a terminal is not one mark. This is the crate's only value not counted off the tree, because nothing has drawn the mark yet. Said so in the header rather than letting it pass as another measurement. Reduced motion opens here because a blink is the first token whose whole existence is movement. Two halves, since a browser and an egui window learn the preference differently: a `prefers-reduced-motion` block ships with the values it overrides, and every other renderer asks `activity_blink(reduced)` the way it already asks `notice_lifetime(transient)`. `None` means lit and still, not absent -- reduced motion asks for less movement, not less information, and a renderer that hides the mark has answered the wrong request. One thing the zero does not do on its own, and the doc says so rather than implying it: a zero-length animation with no fill-mode leaves the element in its base style, not at its last keyframe. So a consuming rule has to draw the mark lit in the base style and dim it in the keyframes. Inverted, reduced motion would blank it. Intent is untouched by the motion-off block. Those are waits rather than strokes, and a reader asking for less motion has not asked for their input to settle sooner. 0.1.1, not 0.2.0: everything here is additive, so audiofiles' `"0.1"` requirement keeps resolving and there is no consumer to forward-fix.
Author: Max Johnson <me@maxj.phd> · 2026-08-26 14:54 UTC
Signed with PGP, not checked
Commit: 1cdc429562a60873407690d2e3d1bf94c030a40d
Parent: b8cd37b
2 files changed, +278 insertions, -3 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-timing"
3 - version = "0.1.0"
3 + version = "0.1.1"
4 4 edition = "2024"
5 5 description = "The time axis of the make-family design system: a duration is named by what it is waiting for, never by a number. Four intents, one duration each, the same on every renderer."
6 6 license = "MIT"
M src/lib.rs +277 -2
@@ -74,6 +74,33 @@
74 74 //! It is an enum rather than a constant so the second one has somewhere to go,
75 75 //! and it grows when something is measured, not when a scale looks short.
76 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 + //!
77 104 //! # Where the numbers came from
78 105 //!
79 106 //! Every value below is a count from the tree, taken 2026-08-18 and re-checked
@@ -91,6 +118,13 @@
91 118 //! typeaheads sit at 200ms against everything else's 150ms. 150 wins on the
92 119 //! count and on the cross-renderer agreement, and the 200s conform.
93 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 + //!
94 128 //! # Consumers
95 129 //!
96 130 //! Web surfaces bake [`timing_css`] in at build time. Nothing here changes at
@@ -241,6 +275,116 @@
241 275 }
242 276 }
243 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 +
244 388 /// How long a notice lives, given whether the description calls it transient.
245 389 ///
246 390 /// The seam this crate was built for. `makeover-layout` documents
@@ -293,17 +437,67 @@
293 437 for motion in Motion::all() {
294 438 let _ = writeln!(out, " --{}: {};", motion.token(), motion.css());
295 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 + }
296 446 out
297 447 }
298 448
299 - /// Emit the whole time axis as a `:root { … }` block.
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.
300 485 ///
301 486 /// Mirrors `makeover_geometry::geometry_css_vars`. Like geometry and unlike
302 487 /// colour, none of this varies at runtime, so a web consumer bakes it in at
303 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.
304 494 #[must_use]
305 495 pub fn timing_css_vars() -> String {
306 - format!(":root {{\n{}}}\n", timing_css_declarations())
496 + format!(
497 + ":root {{\n{}}}\n\n{}",
498 + timing_css_declarations(),
499 + reduced_motion_css()
500 + )
307 501 }
308 502
309 503 /// The time axis as a stylesheet, inside the family's cascade layer.
@@ -333,6 +527,10 @@
333 527 for motion in Motion::all() {
334 528 assert_eq!(motion.duration().as_millis() as u32, motion.ms());
335 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 + }
336 534 }
337 535
338 536 #[test]
@@ -345,6 +543,10 @@
345 543 assert_eq!(Intent::Dismiss.ms(), 3000);
346 544 assert_eq!(Intent::Debounce.ms(), 150);
347 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);
348 550 }
349 551
350 552 #[test]
@@ -403,6 +605,10 @@
403 605 let decl = format!("--{}: {}", motion.token(), motion.css());
404 606 assert_eq!(css.matches(&decl).count(), 1, "{}", motion.token());
405 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 + }
406 612 }
407 613
408 614 #[test]
@@ -419,6 +625,74 @@
419 625 .iter()
420 626 .all(|m| m.token().starts_with("motion-"))
421 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 + );
422 696 }
423 697
424 698 #[test]
@@ -428,6 +702,7 @@
428 702 // choose between.
429 703 let mut tokens: Vec<&str> = Intent::all().iter().map(|i| i.token()).collect();
430 704 tokens.extend(Motion::all().iter().map(|m| m.token()));
705 + tokens.extend(Cadence::all().iter().map(|c| c.token()));
431 706 let mut sorted = tokens.clone();
432 707 sorted.sort_unstable();
433 708 sorted.dedup();