Skip to main content

max / makeover-timing

4.9 KB · 127 lines History Blame Raw
1 # makeover-timing
2
3 The time axis of the make-family design system.
4 [`makeover`]https://makenot.work/git/max/makeover resolves colour,
5 [`makeover-geometry`]https://makenot.work/git/max/makeover-geometry resolves
6 distance, and
7 [`makeover-layout`]https://makenot.work/git/max/makeover-layout names what a
8 thing is without resolving anything. This crate answers the remaining question
9 of the same shape: how long.
10
11 ## A duration is named by what it is waiting for
12
13 | Intent | What it waits for | Resolves to |
14 |---|---|---|
15 | `revert` | a control returning to its resting label after confirming | 1500ms |
16 | `clear` | a status line emptying itself | 2000ms |
17 | `dismiss` | a transient notice's lifetime | 3000ms |
18 | `debounce` | typing settling before the work starts | 150ms |
19
20 Whether a toast should live 3000ms or 3500ms is unanswerable on its own.
21 Whether a message is a toast or a banner is not. That is the whole argument for
22 the layer, and it is the same one `gap-peer` makes 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 Distance has a renderer axis because a fingertip is coarser than a cursor and a
30 terminal cell is coarser than a pixel: the surface differs. Time does not
31 differ that way. A second is a second in a browser, in egui and in a terminal,
32 and the reader waiting it out is the same reader.
33
34 What looked like a renderer disagreement in the tree turned out to be drift.
35 MNW debounced one typeahead at 150ms and two others at 200ms, in one repo, on
36 one renderer, and a per-renderer table would not have caught it. So a
37 divergence here is a bug report, not an axis.
38
39 ## What is deliberately not a timing intent
40
41 **A race is not an intent.** Waiting 300ms before navigating because the write
42 "should" have landed, or 150ms before closing a dropdown so a mousedown can
43 beat the blur, is a synchronisation bug wearing a duration's clothes. Naming
44 those would launder them into design decisions and give every future one a
45 token to hide behind. They get fixed per screen.
46
47 **Severity is not an intent, and it is not a fifth number.** Two renderers
48 already reached for one: MNW gives an error toast 6000ms against an ordinary
49 one's 3000ms, and audiofiles' footer never expires an error at all. Read
50 together those are one statement, not two durations. A message the user must
51 not miss does not go away on its own, `makeover-layout` already has the word
52 for that (such a message is not `Notice::transient`), and a banner has no
53 lifetime.
54
55 **A poll interval is not an intent.** A retry backoff, a health check, an
56 update check: those are answerable from what they talk to, not from what a
57 reader can follow.
58
59 ## Motion is a separate axis
60
61 `Intent` says how long a state lasts. `Motion` says how long a change takes.
62 CSS itself draws that line, a `setTimeout` against a `transition-duration`, and
63 folding the two together is what makes a "timing scale" unusable: 300ms of fade
64 and 3000ms of toast are not two rungs of one ramp.
65
66 `Motion` has one rung, `fade` at 300ms, because the tree has one measured
67 transition. It grows when something is measured, not when a scale looks short.
68
69 ## Using it
70
71 Web surfaces bake the stylesheet in at build time. Nothing here changes at
72 runtime, so there is no load-time JS step:
73
74 ```rust
75 use makeover_timing::timing_css;
76
77 std::fs::write("static/timing.css", timing_css())?;
78 ```
79
80 ```css
81 :root {
82 --timing-revert: 1500ms;
83 --timing-clear: 2000ms;
84 --timing-dismiss: 3000ms;
85 --timing-debounce: 150ms;
86
87 --motion-fade: 300ms;
88 }
89 ```
90
91 egui and ratatui surfaces resolve through `Duration` instead, which is the
92 reason this is a crate rather than a stylesheet:
93
94 ```rust
95 use makeover_timing::{Intent, Motion, notice_lifetime};
96
97 let settle = Intent::Debounce.duration();
98 let gone = notice_lifetime(true).map(|life| life + Motion::Fade.duration());
99 ```
100
101 `notice_lifetime` is the seam the crate was built for. `makeover-layout`
102 documents `Notice::Toast` as "transient, stacked, dismisses itself" and says
103 nothing about when; this says when, on the renderer's side of the line. It
104 takes the bool rather than the enum so this crate stays off `makeover-layout`'s
105 dependency graph, and the bool is exactly what the description asserts.
106
107 ## Where the numbers came from
108
109 Every value is a count from the tree, taken 2026-08-18 and re-checked
110 2026-08-21, not a preference:
111
112 ```text
113 revert 1500ms MNW: 6 hand-rolled sites, plus core/clipboard.ts's own default
114 clear 2000ms MNW: 4 sites
115 dismiss 3000ms MNW: the toast renderer's lifetime
116 debounce 150ms audiofiles SEARCH_DEBOUNCE, MNW docs-search.js
117 fade 300ms MNW: TOAST_FADE_MS, matching the .fade-out transition
118 ```
119
120 The one contested value is the debounce, where MNW's two category typeaheads
121 sit at 200ms against everything else's 150ms. 150 wins on the count and on the
122 cross-renderer agreement, and the 200s conform.
123
124 ## Licence
125
126 MIT.
127