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}