//! The time axis of the make-family design system. //! //! //! //! [`makeover`] resolves colour, `makeover-geometry` resolves distance, and //! `makeover-layout` names what a thing is without resolving anything. This //! crate answers the remaining question of the same shape: **how long**. //! //! The move is the one the family makes everywhere. A duration is named by //! what it is waiting for, and the number follows: //! //! | Intent | What it waits for | Resolves to | //! |---|---|---| //! | [`Intent::Revert`] | a control returning to its resting label after confirming | 1500ms | //! | [`Intent::Clear`] | a status line emptying itself | 2000ms | //! | [`Intent::Dismiss`] | a transient notice's lifetime | 3000ms | //! | [`Intent::Debounce`] | typing settling before the work starts | 150ms | //! //! Whether a toast should live 3000ms or 3500ms is unanswerable on its own. //! Whether a message is a toast or a banner is not. That is the whole argument //! for the layer, and it is the same one [`Gap`](makeover_geometry::Gap) makes //! about six pixels. //! //! # One duration per intent, on every renderer //! //! An intent resolves to exactly one duration everywhere. Not one per renderer, //! not one per theme. //! //! A per-renderer table, resolving the way //! [`Density`](makeover_geometry::Density) resolves gaps, is the wrong shape. //! Distance has a renderer axis because a fingertip is coarser than a cursor //! and a terminal cell is coarser than a pixel: the *surface* differs. Time //! does not differ that way. A second is a second in a browser, in egui and in //! a terminal, and the reader waiting it out is the same reader. //! //! So a divergence here is a bug report, not an axis. //! //! # What is deliberately not a timing intent //! //! Three classes are out of scope, and leaving them out is most of what makes //! the four above coherent. //! //! **A race is not an intent.** Waiting 300ms before navigating because the //! write "should" have landed, or 150ms before closing a dropdown so a //! mousedown can beat the blur, is a synchronisation bug wearing a duration's //! clothes. Naming those would launder them into design decisions and give //! every future one a token to hide behind. They get fixed per screen. //! //! **Severity is not an intent either, and it is not a fifth number.** Two //! renderers already reached for one: MNW gives an error toast 6000ms against //! an ordinary one's 3000ms, and audiofiles' footer never expires an error at //! all while an ordinary message goes at 30s. Read together those are not two //! durations, they are one statement — *a message the user must not miss does //! not go away on its own* — and `makeover-layout` already has the word for //! it: such a message is not `Notice::transient`. It is a banner, and a banner //! has no lifetime. See [`notice_lifetime`]. //! //! **A poll interval is not an intent.** A retry backoff, a health check, an //! update check: those are answerable from what they talk to, not from what a //! reader can follow, and nothing here has an opinion about them. //! //! # Motion is a separate axis //! //! [`Intent`] says how long a state lasts. [`Motion`] says how long a change //! takes. CSS itself draws that line — a `setTimeout` against a //! `transition-duration` — and folding the two together is what makes a //! "timing scale" unusable: 300ms of fade and 3000ms of toast are not two //! rungs of one ramp, they are answers to different questions. //! //! [`Motion`] has one rung today because the tree has one measured transition. //! It is an enum rather than a constant so the second one has somewhere to go, //! and it grows when something is measured, not when a scale looks short. //! //! # Cadence is a third question //! //! [`Intent`] is how long a state lasts and [`Motion`] is how long a change //! takes. Both are one-shot: something starts, it ends. [`Cadence`] is how //! often a repeating mark repeats, which neither of the other two can answer //! without lying about its own shape. //! //! The crate header's exclusion of poll intervals does not cover it, and the //! difference is the same one that exclusion rests on. A backoff or a health //! check is answerable from what it talks to. How fast a mark may blink before //! it reads as an alarm is answerable from the reader, which is what every rung //! in this crate is answerable from. //! //! # Reduced motion //! //! The first thing in the family to have a motion-off path, opened here //! because [`Cadence`] is the first token whose whole existence is movement. //! A duration that is not animating anything is unaffected by it: //! [`Intent::Debounce`] is a wait, not a stroke, and reducing motion does not //! make input settle faster. //! //! Two halves, because a browser and an egui window learn about the preference //! differently. A web surface gets a `prefers-reduced-motion` block in the //! generated stylesheet and needs no code. Every other renderer asks //! [`activity_blink`] with the bool its own platform gave it, exactly as it //! asks [`notice_lifetime`] with the bool the description gave it. //! //! # Where the numbers came from //! //! Every value below is a count from the tree, not a preference: //! //! ```text //! revert 1500ms MNW: 6 hand-rolled sites, plus core/clipboard.ts's own default //! clear 2000ms MNW: 4 sites //! dismiss 3000ms MNW: the toast renderer's lifetime //! debounce 150ms audiofiles SEARCH_DEBOUNCE, MNW docs-search.js //! fade 300ms MNW: TOAST_FADE_MS, matching the .fade-out transition //! ``` //! //! The one contested value is the debounce, where MNW's two category //! typeaheads sit at 200ms against everything else's 150ms. 150 wins on the //! count and on the cross-renderer agreement, and the 200s conform. //! //! [`Cadence::Activity`] is the one rung not counted off the tree. Wiki //! `loading-and-progress-standard` rules that an unmeasured wait blinks rather //! than spins; this is the cadence it blinks at. What it is answerable to is //! stated on the member. //! //! # Consumers //! //! Web surfaces bake [`timing_css`] in at build time. Nothing here changes at //! runtime, so there is no load-time JS step, exactly as with geometry. egui //! and ratatui surfaces read [`Intent::duration`] instead, which is why this //! is a crate rather than a stylesheet. //! //! [`makeover`]: https://makenot.work/git/max/makeover #![forbid(unsafe_code)] use std::fmt::Write as _; use std::time::Duration; use makeover_geometry::in_css_layer; /// A duration named by what it is waiting for. /// /// Four members, and the set is closed on purpose: each one is a thing a /// reader is waiting through, and the crate header says what was measured out. /// Adding a fifth means naming a wait nobody here is already having. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum Intent { /// How long a control shows that it did something before returning to its /// resting label. /// /// "Copied!" on a button that said "Copy link". Long enough to be read /// after the eye has moved back to it, short enough that the control is /// honest about its own label again before the next click. Revert, /// How long a status line holds a message before emptying itself. /// /// The message is a receipt for something the user just did, so it is read /// or not read immediately. Holding it longer means the next glance at that /// line reports stale news. Clear, /// How long a transient notice lives before it starts to leave. /// /// Excludes the leaving itself, which is [`Motion::Fade`]. A notice the /// user must not miss is not transient and gets no lifetime at all; see /// [`notice_lifetime`]. Dismiss, /// How long input waits to settle before the work behind it starts. /// /// A search field that queries on every keystroke, filtered through this. /// The number is a claim about typing rather than about the query: below /// roughly 100ms an ordinary typist trips it mid-word, and above roughly /// 250ms the field feels like it stopped listening. Debounce, } impl Intent { /// The duration in whole milliseconds. /// /// The primary resolution. [`Self::duration`] and [`Self::css`] are both /// spellings of this number, so there is exactly one place it lives. #[must_use] pub const fn ms(self) -> u32 { match self { Self::Revert => 1500, Self::Clear => 2000, Self::Dismiss => 3000, Self::Debounce => 150, } } /// The duration as a [`Duration`], for the renderers that are not a /// browser. #[must_use] pub const fn duration(self) -> Duration { Duration::from_millis(self.ms() as u64) } /// The CSS custom property name, without the leading dashes. #[must_use] pub const fn token(self) -> &'static str { match self { Self::Revert => "timing-revert", Self::Clear => "timing-clear", Self::Dismiss => "timing-dismiss", Self::Debounce => "timing-debounce", } } /// The CSS value, as a `ms` time. /// /// Milliseconds rather than seconds at every rung, including the ones that /// divide evenly: a stylesheet where some durations read `1.5s` and others /// `150ms` cannot be scanned for the odd one out. #[must_use] pub fn css(self) -> String { format!("{}ms", self.ms()) } /// Every intent, in the order they are emitted. #[must_use] pub const fn all() -> [Self; 4] { [Self::Revert, Self::Clear, Self::Dismiss, Self::Debounce] } } /// How long a change takes, as opposed to how long a state lasts. /// /// See the crate header for why this is not a fifth [`Intent`]. One rung, and /// it grows from a measurement rather than from the scale looking short. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum Motion { /// A thing leaving: opacity to zero, then gone. /// /// The whole of the departure. A renderer that removes the node itself has /// to wait this out first, or it drops the animation mid-stroke. Fade, } impl Motion { /// The duration in whole milliseconds. #[must_use] pub const fn ms(self) -> u32 { match self { Self::Fade => 300, } } /// The duration as a [`Duration`]. #[must_use] pub const fn duration(self) -> Duration { Duration::from_millis(self.ms() as u64) } /// The CSS custom property name, without the leading dashes. #[must_use] pub const fn token(self) -> &'static str { match self { Self::Fade => "motion-fade", } } /// The CSS value, as a `ms` time. #[must_use] pub fn css(self) -> String { format!("{}ms", self.ms()) } /// Every motion, in the order they are emitted. #[must_use] pub const fn all() -> [Self; 1] { [Self::Fade] } } /// How often a repeating mark repeats. /// /// The third question, and see the crate header for why it is neither an /// [`Intent`] nor a [`Motion`]. One rung, and it grows the same way the others /// do. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum Cadence { /// How long the activity mark holds each of its two states. /// /// The hard-disk light: a small mark saying that something is happening, /// on a wait with no countable size. `makeover-layout`'s `Awaiting` is what /// says the wait has no size; this is how fast the answer to that blinks. /// /// **A half-period, not a cycle.** The mark is lit for this long, dark for /// this long, and a full cycle is twice it. One number rather than a period /// plus a duty cycle, because two numbers are two things three renderers /// can disagree about, and a mark that is lit a fifth of the time in a /// browser and half the time in a terminal is not one mark. /// /// 500ms, so a cycle is a second. Not a count off the tree like every other /// rung, since nothing has drawn this yet. What it is answerable to is the /// eye: fast enough that a glance catches it working, slow enough that it /// reads as steady work rather than as an alarm. A blink much under half a /// second is a strobe and starts to claim urgency the wait has not earned. /// /// This is the *fallback*, and on most surfaces it should be the rarer /// case. Where the activity is observable the mark follows it, per rule 3 /// of wiki `loading-and-progress-standard`: an upload blinks per chunk /// delivered, and this is for the waits with nothing to watch. Activity, } impl Cadence { /// The duration in whole milliseconds. #[must_use] pub const fn ms(self) -> u32 { match self { Self::Activity => 500, } } /// The duration as a [`Duration`]. #[must_use] pub const fn duration(self) -> Duration { Duration::from_millis(self.ms() as u64) } /// One full on-and-off cycle, which is twice the half-period. /// /// Spelled here rather than doubled at each call site: a renderer driving a /// two-state toggle wants [`duration`](Self::duration), and one scheduling /// a whole cycle wants this, and neither should be doing the arithmetic. #[must_use] pub const fn cycle(self) -> Duration { Duration::from_millis(self.ms() as u64 * 2) } /// The CSS custom property name, without the leading dashes. #[must_use] pub const fn token(self) -> &'static str { match self { Self::Activity => "cadence-activity", } } /// The CSS value, as a `ms` time. #[must_use] pub fn css(self) -> String { format!("{}ms", self.ms()) } /// Every cadence, in the order they are emitted. #[must_use] pub const fn all() -> [Self; 1] { [Self::Activity] } } /// How fast the activity mark blinks, given whether the reader has asked for /// less motion. /// /// The same seam as [`notice_lifetime`], and it takes a bool for the same /// reason: the preference is the platform's to report, and taking the answer /// rather than the platform keeps this crate off everyone's dependency graph. /// /// ``` /// # use makeover_timing::{Cadence, activity_blink}; /// assert_eq!(activity_blink(false), Some(Cadence::Activity.duration())); /// assert_eq!(activity_blink(true), None); /// ``` /// /// `None` is not "the caller decides" and it is not "draw nothing". It means /// the mark does not blink: it is drawn, lit, and still, for as long as the /// wait lasts. The reader still learns that something is happening, which is /// the whole content of the mark; what they are spared is the movement. A /// renderer that hides the mark instead has removed the information rather than /// the animation, and reduced motion asks for the second. /// /// A web surface does not call this. The generated stylesheet carries a /// `prefers-reduced-motion` block that does the same thing in the cascade, /// which is why [`reduced_motion_css`] exists. #[must_use] pub const fn activity_blink(reduced: bool) -> Option { if reduced { None } else { Some(Cadence::Activity.duration()) } } /// How long a notice lives, given whether the description calls it transient. /// /// The seam this crate was built for. `makeover-layout` documents /// `Notice::Toast` as "transient, stacked, dismisses itself" and /// `Notice::transient()` returns true for it — the description says a notice /// goes away on its own and deliberately says nothing about when. This is /// when, on the renderer's side of the line: /// /// ``` /// # use makeover_timing::{Intent, notice_lifetime}; /// // notice_lifetime(makeover_layout::Notice::Toast.transient()) /// assert_eq!(notice_lifetime(true), Some(Intent::Dismiss.duration())); /// assert_eq!(notice_lifetime(false), None); /// ``` /// /// Taking the bool rather than the enum is what keeps this crate off /// `makeover-layout`'s dependency graph, and the bool is exactly what the /// description asserts. A renderer already holds the `Notice`. /// /// `None` is not "the caller decides". It means the notice has no lifetime: /// a banner is dismissed by fixing the cause, and an error is a banner. See /// the crate header on why severity is not a fifth duration. #[must_use] pub const fn notice_lifetime(transient: bool) -> Option { if transient { Some(Intent::Dismiss.duration()) } else { None } } /// Emit the time axis as CSS declarations, no selector. /// /// [`Intent::Debounce`] is emitted with the rest even though no stylesheet can /// use it. The point of the layer is that one document holds every duration in /// the system; a token that lives here for three of the four intents and in a /// JS constant for the fourth is the drift this crate exists to end, and a /// script can read the value back off the computed style. #[must_use] pub fn timing_css_declarations() -> String { let mut out = String::new(); out.push_str(" /* Time. Named for what is being waited on; the number\n"); out.push_str(" follows. One duration per intent on every renderer —\n"); out.push_str(" a divergence here is a bug report, not an axis. */\n"); for intent in Intent::all() { let _ = writeln!(out, " --{}: {};", intent.token(), intent.css()); } out.push_str("\n /* Motion: how long a change takes, not how long a state\n"); out.push_str(" lasts. A separate question, so a separate axis. */\n"); for motion in Motion::all() { let _ = writeln!(out, " --{}: {};", motion.token(), motion.css()); } out.push_str("\n /* Cadence: how often a repeating mark repeats. A\n"); out.push_str(" half-period, so a full cycle is twice it. */\n"); for cadence in Cadence::all() { let _ = writeln!(out, " --{}: {};", cadence.token(), cadence.css()); } out } /// The motion-off block, for a reader who has asked for less of it. /// /// Everything here that animates resolves to `0ms`. A zero-length transition /// is a jump to its end state, so a notice stops fading and simply goes when /// its lifetime is up, with no rule written twice. /// /// **The blink needs one thing from the rule that consumes it**, and it does /// not fall out of the zero on its own. A zero-length animation with no /// `animation-fill-mode` leaves the element in its *base* style, not at its /// last keyframe. So write the rule with the mark lit in the base style and the /// keyframes doing the dimming, never the other way round. Then reduced motion /// stills a lit mark, and the inverted spelling would blank it. See /// [`activity_blink`] on why removing the mark answers a different request from /// the one that was made. /// /// [`Intent`] is untouched. Those are waits rather than strokes, and a reader /// asking for less motion has not asked for their input to settle sooner or for /// a notice they are reading to leave early. #[must_use] pub fn reduced_motion_css() -> String { let mut out = String::new(); out.push_str("@media (prefers-reduced-motion: reduce) {\n"); out.push_str(" :root {\n"); out.push_str(" /* Motion off. The state a stroke was heading for,\n"); out.push_str(" reached at once; nothing is removed. */\n"); for motion in Motion::all() { let _ = writeln!(out, " --{}: 0ms;", motion.token()); } for cadence in Cadence::all() { let _ = writeln!(out, " --{}: 0ms;", cadence.token()); } out.push_str(" }\n}\n"); out } /// Emit the whole time axis: a `:root { … }` block, then the motion-off block. /// /// Mirrors `makeover_geometry::geometry_css_vars`. Like geometry and unlike /// colour, none of this varies at runtime, so a web consumer bakes it in at /// build time rather than applying it from JS on load. /// /// The `prefers-reduced-motion` block rides with the values it overrides rather /// than being a second thing to remember to include. A consumer that took the /// vars and not the block would animate at every rung for a reader who asked it /// not to, and would do it silently. #[must_use] pub fn timing_css_vars() -> String { format!( ":root {{\n{}}}\n\n{}", timing_css_declarations(), reduced_motion_css() ) } /// The time axis as a stylesheet, inside the family's cascade layer. /// /// The whole-file entry point, and the one a build script should call. /// Unlayered declarations outrank every named layer, so generated CSS that /// stays outside the layer beats the app's own overrides regardless of /// specificity — which is invisible until the app adopts layers, and then is a /// puzzle. `makeover_geometry::CSS_LAYER` is the one spelling of the name. #[must_use] pub fn timing_css() -> String { in_css_layer(&timing_css_vars()) } #[cfg(test)] mod tests { use super::*; #[test] fn every_intent_resolves_to_one_number_in_three_spellings() { // ms, Duration and CSS are three renderings of one value, so a rung // cannot drift between the browser and egui. for intent in Intent::all() { assert_eq!(intent.duration().as_millis() as u32, intent.ms()); assert_eq!(intent.css(), format!("{}ms", intent.ms())); } for motion in Motion::all() { assert_eq!(motion.duration().as_millis() as u32, motion.ms()); } for cadence in Cadence::all() { assert_eq!(cadence.duration().as_millis() as u32, cadence.ms()); assert_eq!(cadence.css(), format!("{}ms", cadence.ms())); } } #[test] fn the_measured_values_are_the_ones_the_tree_had() { // Pinned against the 2026-08-18 count. Changing one of these is a // design decision about every consumer at once, which is the point of // the crate; a test failure is the argument happening out loud. assert_eq!(Intent::Revert.ms(), 1500); assert_eq!(Intent::Clear.ms(), 2000); assert_eq!(Intent::Dismiss.ms(), 3000); assert_eq!(Intent::Debounce.ms(), 150); assert_eq!(Motion::Fade.ms(), 300); // The one value that is not a count. Pinned all the same: it is the // number three renderers agree on, which is the whole reason it is // here rather than in each of them. assert_eq!(Cadence::Activity.ms(), 500); } #[test] fn a_notice_leaves_after_its_lifetime_and_its_fade() { // The two numbers the toast class needs, and the reason they are on // different axes: a renderer that removes the node at Dismiss drops // the animation, and one that waits Dismiss + Fade is correct. assert_eq!(notice_lifetime(true), Some(Duration::from_secs(3))); assert!(Motion::Fade.duration() < Intent::Dismiss.duration()); } #[test] fn a_notice_that_is_not_transient_has_no_lifetime() { // Not "the caller decides" — a banner is dismissed by fixing the cause. // This is where an error toast's second number went. assert_eq!(notice_lifetime(false), None); } #[test] fn debounce_is_the_shortest_wait_and_a_notice_the_longest() { // The ordering is the sanity check on the set: input settling is the // one wait a user is inside rather than watching, so it is the only // sub-second rung, and nothing may quietly grow past a notice. assert!( Intent::all() .iter() .all(|i| i.ms() >= Intent::Debounce.ms()) ); assert!(Intent::all().iter().all(|i| i.ms() <= Intent::Dismiss.ms())); } #[test] fn no_intent_is_long_enough_to_be_a_poll_interval() { // A ceiling with an argument behind it: every rung here is a wait a // reader sits through, and past a few seconds that stops being true. // A backoff or a health check answers to what it talks to, not here. assert!(Intent::all().iter().all(|i| i.ms() <= 5_000)); } #[test] fn the_layer_is_emitted_inside_the_family_layer() { let css = timing_css(); assert!(css.starts_with("@layer makeover {\n")); assert!(css.contains(" :root {")); assert!(css.trim_end().ends_with('}')); } #[test] fn every_token_reaches_the_stylesheet_exactly_once() { let css = timing_css(); for intent in Intent::all() { let decl = format!("--{}: {}", intent.token(), intent.css()); assert_eq!(css.matches(&decl).count(), 1, "{}", intent.token()); } for motion in Motion::all() { let decl = format!("--{}: {}", motion.token(), motion.css()); assert_eq!(css.matches(&decl).count(), 1, "{}", motion.token()); } for cadence in Cadence::all() { let decl = format!("--{}: {}", cadence.token(), cadence.css()); assert_eq!(css.matches(&decl).count(), 1, "{}", cadence.token()); } } #[test] fn tokens_are_prefixed_by_their_axis() { // `--timing-*` for a state's length, `--motion-*` for a change's. A // reader scanning the sheet can tell which question a var answers. assert!( Intent::all() .iter() .all(|i| i.token().starts_with("timing-")) ); assert!( Motion::all() .iter() .all(|m| m.token().starts_with("motion-")) ); assert!( Cadence::all() .iter() .all(|c| c.token().starts_with("cadence-")) ); } #[test] fn the_blink_is_a_half_period_and_the_cycle_is_twice_it() { // The one arithmetic a renderer must not be doing itself. A mark lit // for 500ms and dark for 500ms is a one-second cycle, and a renderer // scheduling the cycle where it meant the half draws at half speed. assert_eq!(Cadence::Activity.cycle(), Cadence::Activity.duration() * 2); assert_eq!(Cadence::Activity.cycle(), Duration::from_secs(1)); } #[test] fn the_blink_is_slower_than_a_stroke_and_faster_than_a_notice() { // Where it sits between the existing axes is the sanity check on the // number. Faster than Fade and it is a strobe; slower than the shortest // thing a reader sits through and it reads as stalled rather than busy. assert!(Cadence::Activity.duration() > Motion::Fade.duration()); assert!(Cadence::Activity.duration() < Intent::Revert.duration()); } #[test] fn reduced_motion_stills_the_mark_rather_than_removing_it() { // None means lit and static, not absent. The distinction is the whole // of what reduced motion asks for: less movement, not less information. assert_eq!(activity_blink(false), Some(Cadence::Activity.duration())); assert_eq!(activity_blink(true), None); } #[test] fn reduced_motion_leaves_the_waits_alone() { // Only what animates is zeroed. A debounce is a wait, and a reader // asking for less motion has not asked their input to settle sooner. let css = reduced_motion_css(); for intent in Intent::all() { assert!(!css.contains(intent.token()), "{}", intent.token()); } for motion in Motion::all() { assert!(css.contains(&format!("--{}: 0ms;", motion.token()))); } for cadence in Cadence::all() { assert!(css.contains(&format!("--{}: 0ms;", cadence.token()))); } } #[test] fn the_motion_off_block_ships_with_the_values_it_overrides() { // A consumer taking the vars and not the block animates at every rung // for a reader who asked it not to, and does it silently. So the // whole-file entry point carries both, inside the family layer. let css = timing_css(); assert!(css.contains("@media (prefers-reduced-motion: reduce)")); let vars = css .find(&format!( "--{}: {}", Cadence::Activity.token(), Cadence::Activity.css() )) .expect("the value"); let off = css.find("prefers-reduced-motion").expect("the block"); assert!( vars < off, "the override has to come after what it overrides" ); } #[test] fn no_two_rungs_share_a_name_or_a_value() { // A duplicate name silently overwrites in the cascade; a duplicate // value is two names for one thing, which is a distinction nobody can // choose between. let mut tokens: Vec<&str> = Intent::all().iter().map(|i| i.token()).collect(); tokens.extend(Motion::all().iter().map(|m| m.token())); tokens.extend(Cadence::all().iter().map(|c| c.token())); let mut sorted = tokens.clone(); sorted.sort_unstable(); sorted.dedup(); assert_eq!(sorted.len(), tokens.len(), "{tokens:?}"); let mut values: Vec = Intent::all().iter().map(|i| i.ms()).collect(); values.sort_unstable(); values.dedup(); assert_eq!(values.len(), Intent::all().len(), "two intents, one number"); } }