Skip to main content

makeover_timing/
lib.rs

1//! The time axis of the make-family design system.
2//!
3//! <!-- wiki: makeover-timing -->
4//!
5//! [`makeover`] resolves colour, `makeover-geometry` resolves distance, and
6//! `makeover-layout` names what a thing is without resolving anything. This
7//! crate answers the remaining question of the same shape: **how long**.
8//!
9//! The move is the one the family makes everywhere. A duration is named by
10//! what it is waiting for, and the number follows:
11//!
12//! | Intent | What it waits for | Resolves to |
13//! |---|---|---|
14//! | [`Intent::Revert`] | a control returning to its resting label after confirming | 1500ms |
15//! | [`Intent::Clear`] | a status line emptying itself | 2000ms |
16//! | [`Intent::Dismiss`] | a transient notice's lifetime | 3000ms |
17//! | [`Intent::Debounce`] | typing settling before the work starts | 150ms |
18//!
19//! Whether a toast should live 3000ms or 3500ms is unanswerable on its own.
20//! Whether a message is a toast or a banner is not. That is the whole argument
21//! for the layer, and it is the same one [`Gap`](makeover_geometry::Gap) makes
22//! about six pixels.
23//!
24//! # One duration per intent, on every renderer
25//!
26//! An intent resolves to exactly one duration everywhere. Not one per renderer,
27//! not one per theme.
28//!
29//! The alternative was considered and rejected on 2026-08-21: a per-renderer
30//! table, resolving the way [`Density`](makeover_geometry::Density) resolves
31//! gaps. Distance has a renderer axis because a fingertip is coarser than a
32//! cursor and a terminal cell is coarser than a pixel — the *surface* differs.
33//! Time does not differ that way. A second is a second in a browser, in egui
34//! and in a terminal, and the reader waiting it out is the same reader. What
35//! looked like a renderer disagreement in the tree turned out to be drift: MNW
36//! debounced one typeahead at 150ms and two others at 200ms, in one repo, on
37//! one renderer, and no per-renderer table would have caught that.
38//!
39//! So a divergence here is a bug report, not an axis.
40//!
41//! # What is deliberately not a timing intent
42//!
43//! Three classes were measured out of scope on 2026-08-18, and leaving them
44//! out is most of what makes the four above coherent.
45//!
46//! **A race is not an intent.** Waiting 300ms before navigating because the
47//! write "should" have landed, or 150ms before closing a dropdown so a
48//! mousedown can beat the blur, is a synchronisation bug wearing a duration's
49//! clothes. Naming those would launder them into design decisions and give
50//! every future one a token to hide behind. They get fixed per screen.
51//!
52//! **Severity is not an intent either, and it is not a fifth number.** Two
53//! renderers already reached for one: MNW gives an error toast 6000ms against
54//! an ordinary one's 3000ms, and audiofiles' footer never expires an error at
55//! all while an ordinary message goes at 30s. Read together those are not two
56//! durations, they are one statement — *a message the user must not miss does
57//! not go away on its own* — and `makeover-layout` already has the word for
58//! it: such a message is not `Notice::transient`. It is a banner, and a banner
59//! has no lifetime. See [`notice_lifetime`].
60//!
61//! **A poll interval is not an intent.** A retry backoff, a health check, an
62//! update check: those are answerable from what they talk to, not from what a
63//! reader can follow, and nothing here has an opinion about them.
64//!
65//! # Motion is a separate axis
66//!
67//! [`Intent`] says how long a state lasts. [`Motion`] says how long a change
68//! takes. CSS itself draws that line — a `setTimeout` against a
69//! `transition-duration` — and folding the two together is what makes a
70//! "timing scale" unusable: 300ms of fade and 3000ms of toast are not two
71//! rungs of one ramp, they are answers to different questions.
72//!
73//! [`Motion`] has one rung today because the tree has one measured transition.
74//! It is an enum rather than a constant so the second one has somewhere to go,
75//! and it grows when something is measured, not when a scale looks short.
76//!
77//! # Where the numbers came from
78//!
79//! Every value below is a count from the tree, taken 2026-08-18 and re-checked
80//! 2026-08-21, not a preference:
81//!
82//! ```text
83//! revert     1500ms   MNW: 6 hand-rolled sites, plus core/clipboard.ts's own default
84//! clear      2000ms   MNW: 4 sites
85//! dismiss    3000ms   MNW: the toast renderer's lifetime
86//! debounce    150ms   audiofiles SEARCH_DEBOUNCE, MNW docs-search.js
87//! fade        300ms   MNW: TOAST_FADE_MS, matching the .fade-out transition
88//! ```
89//!
90//! The one contested value is the debounce, where MNW's two category
91//! typeaheads sit at 200ms against everything else's 150ms. 150 wins on the
92//! count and on the cross-renderer agreement, and the 200s conform.
93//!
94//! # Consumers
95//!
96//! Web surfaces bake [`timing_css`] in at build time. Nothing here changes at
97//! runtime, so there is no load-time JS step, exactly as with geometry. egui
98//! and ratatui surfaces read [`Intent::duration`] instead, which is why this
99//! is a crate rather than a stylesheet.
100//!
101//! [`makeover`]: https://makenot.work/git/max/makeover
102
103#![forbid(unsafe_code)]
104
105use std::fmt::Write as _;
106use std::time::Duration;
107
108use makeover_geometry::in_css_layer;
109
110/// A duration named by what it is waiting for.
111///
112/// Four members, and the set is closed on purpose: each one is a thing a
113/// reader is waiting through, and the crate header says what was measured out.
114/// Adding a fifth means naming a wait nobody here is already having.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
116pub enum Intent {
117    /// How long a control shows that it did something before returning to its
118    /// resting label.
119    ///
120    /// "Copied!" on a button that said "Copy link". Long enough to be read
121    /// after the eye has moved back to it, short enough that the control is
122    /// honest about its own label again before the next click.
123    Revert,
124    /// How long a status line holds a message before emptying itself.
125    ///
126    /// The message is a receipt for something the user just did, so it is read
127    /// or not read immediately. Holding it longer means the next glance at that
128    /// line reports stale news.
129    Clear,
130    /// How long a transient notice lives before it starts to leave.
131    ///
132    /// Excludes the leaving itself, which is [`Motion::Fade`]. A notice the
133    /// user must not miss is not transient and gets no lifetime at all; see
134    /// [`notice_lifetime`].
135    Dismiss,
136    /// How long input waits to settle before the work behind it starts.
137    ///
138    /// A search field that queries on every keystroke, filtered through this.
139    /// The number is a claim about typing rather than about the query: below
140    /// roughly 100ms an ordinary typist trips it mid-word, and above roughly
141    /// 250ms the field feels like it stopped listening.
142    Debounce,
143}
144
145impl Intent {
146    /// The duration in whole milliseconds.
147    ///
148    /// The primary resolution. [`Self::duration`] and [`Self::css`] are both
149    /// spellings of this number, so there is exactly one place it lives.
150    #[must_use]
151    pub const fn ms(self) -> u32 {
152        match self {
153            Self::Revert => 1500,
154            Self::Clear => 2000,
155            Self::Dismiss => 3000,
156            Self::Debounce => 150,
157        }
158    }
159
160    /// The duration as a [`Duration`], for the renderers that are not a
161    /// browser.
162    #[must_use]
163    pub const fn duration(self) -> Duration {
164        Duration::from_millis(self.ms() as u64)
165    }
166
167    /// The CSS custom property name, without the leading dashes.
168    #[must_use]
169    pub const fn token(self) -> &'static str {
170        match self {
171            Self::Revert => "timing-revert",
172            Self::Clear => "timing-clear",
173            Self::Dismiss => "timing-dismiss",
174            Self::Debounce => "timing-debounce",
175        }
176    }
177
178    /// The CSS value, as a `ms` time.
179    ///
180    /// Milliseconds rather than seconds at every rung, including the ones that
181    /// divide evenly: a stylesheet where some durations read `1.5s` and others
182    /// `150ms` cannot be scanned for the odd one out.
183    #[must_use]
184    pub fn css(self) -> String {
185        format!("{}ms", self.ms())
186    }
187
188    /// Every intent, in the order they are emitted.
189    #[must_use]
190    pub const fn all() -> [Self; 4] {
191        [Self::Revert, Self::Clear, Self::Dismiss, Self::Debounce]
192    }
193}
194
195/// How long a change takes, as opposed to how long a state lasts.
196///
197/// See the crate header for why this is not a fifth [`Intent`]. One rung, and
198/// it grows from a measurement rather than from the scale looking short.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
200pub enum Motion {
201    /// A thing leaving: opacity to zero, then gone.
202    ///
203    /// The whole of the departure. A renderer that removes the node itself has
204    /// to wait this out first, or it drops the animation mid-stroke.
205    Fade,
206}
207
208impl Motion {
209    /// The duration in whole milliseconds.
210    #[must_use]
211    pub const fn ms(self) -> u32 {
212        match self {
213            Self::Fade => 300,
214        }
215    }
216
217    /// The duration as a [`Duration`].
218    #[must_use]
219    pub const fn duration(self) -> Duration {
220        Duration::from_millis(self.ms() as u64)
221    }
222
223    /// The CSS custom property name, without the leading dashes.
224    #[must_use]
225    pub const fn token(self) -> &'static str {
226        match self {
227            Self::Fade => "motion-fade",
228        }
229    }
230
231    /// The CSS value, as a `ms` time.
232    #[must_use]
233    pub fn css(self) -> String {
234        format!("{}ms", self.ms())
235    }
236
237    /// Every motion, in the order they are emitted.
238    #[must_use]
239    pub const fn all() -> [Self; 1] {
240        [Self::Fade]
241    }
242}
243
244/// How long a notice lives, given whether the description calls it transient.
245///
246/// The seam this crate was built for. `makeover-layout` documents
247/// `Notice::Toast` as "transient, stacked, dismisses itself" and
248/// `Notice::transient()` returns true for it — the description says a notice
249/// goes away on its own and deliberately says nothing about when. This is
250/// when, on the renderer's side of the line:
251///
252/// ```
253/// # use makeover_timing::{Intent, notice_lifetime};
254/// // notice_lifetime(makeover_layout::Notice::Toast.transient())
255/// assert_eq!(notice_lifetime(true), Some(Intent::Dismiss.duration()));
256/// assert_eq!(notice_lifetime(false), None);
257/// ```
258///
259/// Taking the bool rather than the enum is what keeps this crate off
260/// `makeover-layout`'s dependency graph, and the bool is exactly what the
261/// description asserts. A renderer already holds the `Notice`.
262///
263/// `None` is not "the caller decides". It means the notice has no lifetime:
264/// a banner is dismissed by fixing the cause, and an error is a banner. See
265/// the crate header on why severity is not a fifth duration.
266#[must_use]
267pub const fn notice_lifetime(transient: bool) -> Option<Duration> {
268    if transient {
269        Some(Intent::Dismiss.duration())
270    } else {
271        None
272    }
273}
274
275/// Emit the time axis as CSS declarations, no selector.
276///
277/// [`Intent::Debounce`] is emitted with the rest even though no stylesheet can
278/// use it. The point of the layer is that one document holds every duration in
279/// the system; a token that lives here for three of the four intents and in a
280/// JS constant for the fourth is the drift this crate exists to end, and a
281/// script can read the value back off the computed style.
282#[must_use]
283pub fn timing_css_declarations() -> String {
284    let mut out = String::new();
285    out.push_str("  /* Time. Named for what is being waited on; the number\n");
286    out.push_str("     follows. One duration per intent on every renderer —\n");
287    out.push_str("     a divergence here is a bug report, not an axis. */\n");
288    for intent in Intent::all() {
289        let _ = writeln!(out, "  --{}: {};", intent.token(), intent.css());
290    }
291    out.push_str("\n  /* Motion: how long a change takes, not how long a state\n");
292    out.push_str("     lasts. A separate question, so a separate axis. */\n");
293    for motion in Motion::all() {
294        let _ = writeln!(out, "  --{}: {};", motion.token(), motion.css());
295    }
296    out
297}
298
299/// Emit the whole time axis as a `:root { … }` block.
300///
301/// Mirrors `makeover_geometry::geometry_css_vars`. Like geometry and unlike
302/// colour, none of this varies at runtime, so a web consumer bakes it in at
303/// build time rather than applying it from JS on load.
304#[must_use]
305pub fn timing_css_vars() -> String {
306    format!(":root {{\n{}}}\n", timing_css_declarations())
307}
308
309/// The time axis as a stylesheet, inside the family's cascade layer.
310///
311/// The whole-file entry point, and the one a build script should call.
312/// Unlayered declarations outrank every named layer, so generated CSS that
313/// stays outside the layer beats the app's own overrides regardless of
314/// specificity — which is invisible until the app adopts layers, and then is a
315/// puzzle. `makeover_geometry::CSS_LAYER` is the one spelling of the name.
316#[must_use]
317pub fn timing_css() -> String {
318    in_css_layer(&timing_css_vars())
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn every_intent_resolves_to_one_number_in_three_spellings() {
327        // ms, Duration and CSS are three renderings of one value, so a rung
328        // cannot drift between the browser and egui.
329        for intent in Intent::all() {
330            assert_eq!(intent.duration().as_millis() as u32, intent.ms());
331            assert_eq!(intent.css(), format!("{}ms", intent.ms()));
332        }
333        for motion in Motion::all() {
334            assert_eq!(motion.duration().as_millis() as u32, motion.ms());
335        }
336    }
337
338    #[test]
339    fn the_measured_values_are_the_ones_the_tree_had() {
340        // Pinned against the 2026-08-18 count. Changing one of these is a
341        // design decision about every consumer at once, which is the point of
342        // the crate; a test failure is the argument happening out loud.
343        assert_eq!(Intent::Revert.ms(), 1500);
344        assert_eq!(Intent::Clear.ms(), 2000);
345        assert_eq!(Intent::Dismiss.ms(), 3000);
346        assert_eq!(Intent::Debounce.ms(), 150);
347        assert_eq!(Motion::Fade.ms(), 300);
348    }
349
350    #[test]
351    fn a_notice_leaves_after_its_lifetime_and_its_fade() {
352        // The two numbers the toast class needs, and the reason they are on
353        // different axes: a renderer that removes the node at Dismiss drops
354        // the animation, and one that waits Dismiss + Fade is correct.
355        assert_eq!(notice_lifetime(true), Some(Duration::from_secs(3)));
356        assert!(Motion::Fade.duration() < Intent::Dismiss.duration());
357    }
358
359    #[test]
360    fn a_notice_that_is_not_transient_has_no_lifetime() {
361        // Not "the caller decides" — a banner is dismissed by fixing the cause.
362        // This is where an error toast's second number went.
363        assert_eq!(notice_lifetime(false), None);
364    }
365
366    #[test]
367    fn debounce_is_the_shortest_wait_and_a_notice_the_longest() {
368        // The ordering is the sanity check on the set: input settling is the
369        // one wait a user is inside rather than watching, so it is the only
370        // sub-second rung, and nothing may quietly grow past a notice.
371        assert!(
372            Intent::all()
373                .iter()
374                .all(|i| i.ms() >= Intent::Debounce.ms())
375        );
376        assert!(Intent::all().iter().all(|i| i.ms() <= Intent::Dismiss.ms()));
377    }
378
379    #[test]
380    fn no_intent_is_long_enough_to_be_a_poll_interval() {
381        // A ceiling with an argument behind it: every rung here is a wait a
382        // reader sits through, and past a few seconds that stops being true.
383        // A backoff or a health check answers to what it talks to, not here.
384        assert!(Intent::all().iter().all(|i| i.ms() <= 5_000));
385    }
386
387    #[test]
388    fn the_layer_is_emitted_inside_the_family_layer() {
389        let css = timing_css();
390        assert!(css.starts_with("@layer makeover {\n"));
391        assert!(css.contains("    :root {"));
392        assert!(css.trim_end().ends_with('}'));
393    }
394
395    #[test]
396    fn every_token_reaches_the_stylesheet_exactly_once() {
397        let css = timing_css();
398        for intent in Intent::all() {
399            let decl = format!("--{}: {}", intent.token(), intent.css());
400            assert_eq!(css.matches(&decl).count(), 1, "{}", intent.token());
401        }
402        for motion in Motion::all() {
403            let decl = format!("--{}: {}", motion.token(), motion.css());
404            assert_eq!(css.matches(&decl).count(), 1, "{}", motion.token());
405        }
406    }
407
408    #[test]
409    fn tokens_are_prefixed_by_their_axis() {
410        // `--timing-*` for a state's length, `--motion-*` for a change's. A
411        // reader scanning the sheet can tell which question a var answers.
412        assert!(
413            Intent::all()
414                .iter()
415                .all(|i| i.token().starts_with("timing-"))
416        );
417        assert!(
418            Motion::all()
419                .iter()
420                .all(|m| m.token().starts_with("motion-"))
421        );
422    }
423
424    #[test]
425    fn no_two_rungs_share_a_name_or_a_value() {
426        // A duplicate name silently overwrites in the cascade; a duplicate
427        // value is two names for one thing, which is a distinction nobody can
428        // choose between.
429        let mut tokens: Vec<&str> = Intent::all().iter().map(|i| i.token()).collect();
430        tokens.extend(Motion::all().iter().map(|m| m.token()));
431        let mut sorted = tokens.clone();
432        sorted.sort_unstable();
433        sorted.dedup();
434        assert_eq!(sorted.len(), tokens.len(), "{tokens:?}");
435
436        let mut values: Vec<u32> = Intent::all().iter().map(|i| i.ms()).collect();
437        values.sort_unstable();
438        values.dedup();
439        assert_eq!(values.len(), Intent::all().len(), "two intents, one number");
440    }
441}