| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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();
|