makeover-timing 0.1.0

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.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
//! The time axis of the make-family design system.
//!
//! <!-- wiki: makeover-timing -->
//!
//! [`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.
//!
//! The alternative was considered and rejected on 2026-08-21: a per-renderer
//! table, resolving the way [`Density`](makeover_geometry::Density) resolves
//! gaps. 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. What
//! looked like a renderer disagreement in the tree turned out to be drift: MNW
//! debounced one typeahead at 150ms and two others at 200ms, in one repo, on
//! one renderer, and no per-renderer table would have caught that.
//!
//! So a divergence here is a bug report, not an axis.
//!
//! # What is deliberately not a timing intent
//!
//! Three classes were measured out of scope on 2026-08-18, 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.
//!
//! # Where the numbers came from
//!
//! Every value below is a count from the tree, taken 2026-08-18 and re-checked
//! 2026-08-21, 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.
//!
//! # 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 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<Duration> {
    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
}

/// Emit the whole time axis as a `:root { … }` 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.
#[must_use]
pub fn timing_css_vars() -> String {
    format!(":root {{\n{}}}\n", timing_css_declarations())
}

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

    #[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);
    }

    #[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());
        }
    }

    #[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-"))
        );
    }

    #[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()));
        let mut sorted = tokens.clone();
        sorted.sort_unstable();
        sorted.dedup();
        assert_eq!(sorted.len(), tokens.len(), "{tokens:?}");

        let mut values: Vec<u32> = Intent::all().iter().map(|i| i.ms()).collect();
        values.sort_unstable();
        values.dedup();
        assert_eq!(values.len(), Intent::all().len(), "two intents, one number");
    }
}