Skip to main content

max / makeover-timing

17.5 KB · 442 lines History Blame Raw
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
105 use std::fmt::Write as _;
106 use std::time::Duration;
107
108 use 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)]
116 pub 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
145 impl 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)]
200 pub 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
208 impl 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]
267 pub 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]
283 pub 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]
305 pub 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]
317 pub fn timing_css() -> String {
318 in_css_layer(&timing_css_vars())
319 }
320
321 #[cfg(test)]
322 mod 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 }
442