Skip to main content

max / makeover-timing

0.1.0: a duration is named by what it is waiting for The ninth crate in the make-family suite, and the last axis of the same shape: makeover resolves colour, makeover-geometry distance, makeover-layout names without resolving. This resolves time. Four intents, one duration each, the same on every renderer. Revert 1500, Clear 2000, Dismiss 3000, Debounce 150, plus Motion::Fade at 300 on its own axis because how long a change takes is not how long a state lasts. Every number is a count from the tree rather than a preference. Races (wait-then-navigate, blur-close) are deliberately not intents, and severity is not a fifth number: a message the user must not miss is not transient, which makeover-layout already has the word for. notice_lifetime takes the bool rather than the Notice so the crate stays off makeover-layout's dependency graph.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 01:56 UTC
Signed with PGP, not checked
Commit: 250a55382bd5f3c5a66a6d67c64c9a693a724a62
11 files changed, +1295 insertions, -0 deletions
A .gitignore +5
@@ -1,0 +1,5 @@
1 + /target
2 +
3 + # Claude Code instructions (project-local; not for the public repo)
4 + CLAUDE.md
5 + /Cargo.lock
A Cargo.toml +46
@@ -1,0 +1,46 @@
1 + [package]
2 + name = "makeover-timing"
3 + version = "0.1.0"
4 + edition = "2024"
5 + description = "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."
6 + license = "MIT"
7 + repository = "https://makenot.work/git/max/makeover-timing"
8 +
9 + [dependencies]
10 + # For the cascade layer name and nothing else. makeover-geometry is the crate
11 + # every CSS-emitting member of the family already depends on, and it is where
12 + # the family keeps the one spelling of `@layer makeover` on purpose: a second
13 + # copy of that string is the drift the constant exists to prevent.
14 + makeover-geometry = "0.7"
15 +
16 + [lints.rust]
17 + unused = "warn"
18 + unreachable_pub = "warn"
19 +
20 + [lints.clippy]
21 + pedantic = { level = "warn", priority = -1 }
22 + # Allow-list tuned from a measured breakdown across server/multithreaded/pter
23 + # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything
24 + # else in `pedantic` stays a warning. Keep this block identical across repos.
25 + module_name_repetitions = "allow"
26 + # Doc lints. No docs-completeness push is underway.
27 + missing_errors_doc = "allow"
28 + missing_panics_doc = "allow"
29 + doc_markdown = "allow"
30 + # Numeric casts. Endemic and mostly intentional in size and byte math.
31 + cast_possible_truncation = "allow"
32 + cast_sign_loss = "allow"
33 + cast_precision_loss = "allow"
34 + cast_possible_wrap = "allow"
35 + cast_lossless = "allow"
36 + # Subjective structure and style nags. High churn, low signal.
37 + must_use_candidate = "allow"
38 + too_many_lines = "allow"
39 + struct_excessive_bools = "allow"
40 + similar_names = "allow"
41 + items_after_statements = "allow"
42 + single_match_else = "allow"
43 + # Frequent false-positives in TUI and router-heavy code.
44 + match_same_arms = "allow"
45 + unnecessary_wraps = "allow"
46 + type_complexity = "allow"
A LICENSE +21
@@ -1,0 +1,21 @@
1 + MIT License
2 +
3 + Copyright (c) 2026 Make Creative, LLC
4 +
5 + Permission is hereby granted, free of charge, to any person obtaining a copy
6 + of this software and associated documentation files (the "Software"), to deal
7 + in the Software without restriction, including without limitation the rights
8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 + copies of the Software, and to permit persons to whom the Software is
10 + furnished to do so, subject to the following conditions:
11 +
12 + The above copyright notice and this permission notice shall be included in all
13 + copies or substantial portions of the Software.
14 +
15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 + SOFTWARE.
A README.md +126
@@ -1,0 +1,126 @@
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.
A bento.toml +8
@@ -1,0 +1,8 @@
1 + # How Bento releases makeover-timing. Lives here rather than in the daemon's config so it is
2 + # versioned with the code it describes.
3 +
4 + # A crate, not an app: one publish.rhai rather than a recipe per platform, and
5 + # the target below names the host that uploads rather than a build matrix.
6 + kind = "library"
7 +
8 + targets = ["linux/x86_64"]
@@ -1,0 +1,64 @@
1 + // Publish this crate to crates.io.
2 + //
3 + // A library has no per-platform artifact, so this is the whole release: one
4 + // recipe, run on whichever host the manifest names.
5 + //
6 + // The preflight step is the point of routing this through Bento. crates.io
7 + // versions can be yanked but never edited, so a wrong repository URL, a
8 + // missing license, or a duplicate version is permanent the moment it uploads.
9 + // pter 0.1.0 went out with a dead repository link and could only be corrected
10 + // by releasing again.
11 +
12 + let h = build_host();
13 + let r = repo();
14 + let v = version();
15 +
16 + // No pull here. The runner's release preflight has already fetched and run
17 + // `git checkout v<version>` on every host, then compared `rev-parse HEAD`
18 + // across them so a release cannot be built from two different commits. That
19 + // leaves the checkout on the tag, detached. Pulling would move it off the tag
20 + // onto the branch tip, publishing something other than what was tagged — and on
21 + // a detached HEAD it just fails, which is how this was found, while publishing
22 + // makeover 2.1.0 as the first library to go through Bento.
23 + //
24 + // So this step asserts the pin instead of re-doing it: HEAD must be exactly a
25 + // tag, or the release is not coming from where it claims.
26 + //
27 + // The tree does not STAY detached: the runner records each host's branch before
28 + // it pins the tag and checks it back out once the build settles. It did not
29 + // always, and makeover shipped 2.3.0 from a checkout three commits ahead of a
30 + // `main` that never moved, with the published commit on no branch and no remote.
31 + step("checkout");
32 + sh_ok(h, "cd " + r + " && git describe --exact-match --tags HEAD");
33 +
34 + // Gate: nothing reaches crates.io from code that fails formatting, clippy or
35 + // its tests. A published version can be yanked but never edited, so this is the
36 + // last point at which a break is still cheap.
37 + //
38 + // fmt runs first, and it is here because it was the one gate missing. It takes
39 + // no features and touches no dependency, so it is the cheapest of the three and
40 + // the one whose failure is never interesting — which is exactly why it drifted:
41 + // makeover-webview's `main` failed `cargo fmt --check` across three releases
42 + // (0.10.0, 0.13.0, 0.14.0) and nothing objected, because the gate ran clippy
43 + // and the suite and never asked. A formatting break costs nothing to fix and
44 + // nothing to catch; leaving it uncaught is what let it accumulate.
45 + //
46 + // `--all` rather than `--workspace`: fmt spells the same idea with the other
47 + // word, and the two are not interchangeable on this subcommand.
48 + step("prebuild");
49 + sh_ok(h, "cd " + r + " && cargo fmt --all --check");
50 + sh_ok(h, "cd " + r + " && cargo clippy --workspace --all-targets " + feature_flags() + " -- -D warnings");
51 + sh_ok(h, "cd " + r + " && cargo test --workspace " + feature_flags());
52 +
53 + step("verify");
54 + // Credentials are checked here too, and deliberately not passed through Bento:
55 + // the token stays in cargo's own 0600 store on the publishing host, where cargo
56 + // finds it. Handing it to a shell command would put it in the process list for
57 + // the length of the upload, and ops-exec renders env pairs into the shell line.
58 + // Aborts the run with the specific problems if anything is wrong.
59 + log(crate_preflight());
60 + sh_ok(h, "cd " + r + " && cargo publish --dry-run " + feature_flags());
61 +
62 + step("publish");
63 + sh_ok(h, "cd " + r + " && cargo publish " + feature_flags());
64 + log("published " + v + " to crates.io");
@@ -1,0 +1,4 @@
1 + [toolchain]
2 + channel = "1.97.1"
3 + profile = "minimal"
4 + components = ["rustfmt", "clippy"]
@@ -1,0 +1,284 @@
1 + #!/usr/bin/env python3
2 + """Do the tree's in-house `version` requirements still resolve?
3 +
4 + DO NOT EDIT IN PLACE. The master is _private/infra/bootstrap/githooks/internal-deps.py.
5 +
6 + Usage:
7 + internal-deps.py <tree-root> [repo-root]
8 +
9 + With a repo root, only pairs that repo is on either side of can fail the run;
10 + everything else is reported as a note. Without one, every pair is graded, which
11 + is the whole-tree report:
12 +
13 + python3 internal-deps.py ~/Code
14 +
15 + WHAT IT GRADES. Every dependency in the tree that carries both a `git` URL on one
16 + of our forges and a `version` requirement, against the version in the working
17 + copy of the crate that URL names. That is the pairing cargo enforces and the one
18 + that broke: a requirement of "0.11" against a sibling that has moved to 0.14 is
19 + not a warning, it is a graph that will not resolve on any machine.
20 +
21 + WHY WORKING COPIES AND NOT REMOTES. `~/Code/.cargo/config.toml` patches every one
22 + of these dependencies to the working copy in the tree, so what is on disk here is
23 + what every local build reads. A bump that has not been pushed yet breaks its
24 + consumers just as thoroughly, and finding that out at push time is the point.
25 +
26 + WHAT IT DOES NOT GRADE, on purpose:
27 +
28 + crates.io deps the makeover suite and friends resolve from the registry,
29 + where working ahead of a release is normal and a tree
30 + version above the published one is not a finding. The
31 + sweep's `coherence` check grades those against the index.
32 + path deps no version requirement to be wrong about.
33 + ranges and wildcards `>=`, `<`, `*` and comma lists are deliberate statements
34 + about a span, not a pin that drifts. Counted as unchecked.
35 + """
36 +
37 + import os
38 + import re
39 + import sys
40 + import tomllib
41 +
42 + # The forges that make a git URL ours. A dependency on somebody else's git repo
43 + # is not something this tree can forward-fix.
44 + OURS = re.compile(r"(makenot\.work|git\.sr\.ht/~maxmj)", re.I)
45 +
46 + # Directories that hold code we do not grade: retired, staged for deletion, or
47 + # not ours. Mirrors the sweep's exclusions rather than inventing a second list.
48 + SKIP_DIRS = {
49 + "target", ".git", "node_modules", "dist", "vendor",
50 + "_archive", "_scratch", "trash", "_meta", "vtebench",
51 + }
52 + MAX_DEPTH = 4
53 +
54 + DEP_SECTIONS = ("dependencies", "dev-dependencies", "build-dependencies")
55 +
56 +
57 + def manifests(root):
58 + """Every Cargo.toml in the tree, shallow-walked."""
59 + out = []
60 + stack = [(root, 0)]
61 + while stack:
62 + d, depth = stack.pop()
63 + try:
64 + entries = list(os.scandir(d))
65 + except OSError:
66 + continue
67 + for e in entries:
68 + if e.is_file() and e.name == "Cargo.toml":
69 + out.append(e.path)
70 + elif e.is_dir() and e.name not in SKIP_DIRS and depth < MAX_DEPTH:
71 + stack.append((e.path, depth + 1))
72 + return out
73 +
74 +
75 + def load(path):
76 + try:
77 + with open(path, "rb") as fh:
78 + return tomllib.load(fh)
79 + except (OSError, tomllib.TOMLDecodeError):
80 + return None
81 +
82 +
83 + def dep_tables(doc):
84 + """Every dependency table in a manifest, including per-target and workspace."""
85 + for section in DEP_SECTIONS:
86 + table = doc.get(section)
87 + if isinstance(table, dict):
88 + yield table
89 + for cfg in (doc.get("target") or {}).values():
90 + if not isinstance(cfg, dict):
91 + continue
92 + for section in DEP_SECTIONS:
93 + table = cfg.get(section)
94 + if isinstance(table, dict):
95 + yield table
96 + ws = doc.get("workspace") or {}
97 + table = ws.get("dependencies")
98 + if isinstance(table, dict):
99 + yield table
100 +
101 +
102 + def parse_version(v):
103 + """A version as a 3-tuple, prerelease dropped. Junk sorts as (0, 0, 0)."""
104 + core = str(v).split("+")[0].split("-")[0]
105 + parts = []
106 + for piece in core.split(".")[:3]:
107 + try:
108 + parts.append(int(piece))
109 + except ValueError:
110 + parts.append(0)
111 + while len(parts) < 3:
112 + parts.append(0)
113 + return tuple(parts)
114 +
115 +
116 + def satisfies(req, version):
117 + """Cargo's default (caret) requirement semantics. None means 'not graded'.
118 +
119 + The rule that matters here is the 0.x one: under 0.1.0 and above, the MINOR
120 + is the compatibility boundary, which is why a 0.11 requirement rejects 0.14
121 + outright rather than treating it as a newer patch.
122 + """
123 + req = req.strip()
124 + if not req or any(c in req for c in "<>*,~"):
125 + return None
126 + # A prerelease satisfies nothing that does not ask for a prerelease of the
127 + # same version, so a plain requirement rejects it. This is the shape the
128 + # maturity ladder produces at beta entry: a sibling at 1.0.0-beta.1 does not
129 + # resolve for a consumer requiring "1.0", and cargo says so.
130 + if "-" in str(version).split("+")[0] and "-" not in req:
131 + return False
132 + exact = req.startswith("=")
133 + req = req.lstrip("^=").strip()
134 + if not req:
135 + return None
136 + given = req.split(".")
137 + try:
138 + r = [int(p) for p in given[:3]]
139 + except ValueError:
140 + return None
141 + v = parse_version(version)
142 + if exact:
143 + return tuple(v[: len(r)]) == tuple(r)
144 + if r[0] > 0:
145 + return v[0] == r[0] and v[1:] >= tuple(r[1:] + [0] * (2 - len(r[1:])))
146 + if len(r) == 1:
147 + return v[0] == 0
148 + if r[1] > 0:
149 + return v[0] == 0 and v[1] == r[1] and v[2] >= (r[2] if len(r) > 2 else 0)
150 + # 0.0.x: every patch is its own compatibility island.
151 + if len(r) > 2:
152 + return v[:3] == (0, 0, r[2])
153 + return v[0] == 0 and v[1] == 0
154 +
155 +
156 + def main():
157 + if len(sys.argv) < 2:
158 + print(__doc__.strip(), file=sys.stderr)
159 + return 2
160 + tree = os.path.realpath(sys.argv[1])
161 + repo = os.path.realpath(sys.argv[2]) if len(sys.argv) > 2 else None
162 +
163 + paths = manifests(tree)
164 + docs = {p: load(p) for p in paths}
165 +
166 + # Workspace versions first: a member saying `version.workspace = true` gets
167 + # its number from the root, and reporting it as 0.0.0 would be a false break.
168 + ws_version = {}
169 + for p, doc in docs.items():
170 + if not doc:
171 + continue
172 + v = ((doc.get("workspace") or {}).get("package") or {}).get("version")
173 + if isinstance(v, str):
174 + ws_version[os.path.dirname(p)] = v
175 +
176 + def resolve_version(manifest_path, pkg):
177 + v = pkg.get("version")
178 + if isinstance(v, str):
179 + return v
180 + d = os.path.dirname(manifest_path)
181 + while d.startswith(tree):
182 + if d in ws_version:
183 + return ws_version[d]
184 + d = os.path.dirname(d)
185 + return None
186 +
187 + # crate name -> (version, manifest path)
188 + versions = {}
189 + for p, doc in docs.items():
190 + if not doc:
191 + continue
192 + pkg = doc.get("package")
193 + if not isinstance(pkg, dict) or not isinstance(pkg.get("name"), str):
194 + continue
195 + v = resolve_version(p, pkg)
196 + if v:
197 + versions[pkg["name"]] = (v, p)
198 +
199 + broken, unchecked, absent, graded = [], 0, set(), 0
200 + for p, doc in docs.items():
201 + if not doc:
202 + continue
203 + for table in dep_tables(doc):
204 + for key, spec in table.items():
205 + if not isinstance(spec, dict):
206 + continue
207 + git = spec.get("git")
208 + req = spec.get("version")
209 + if not isinstance(git, str) or not isinstance(req, str):
210 + continue
211 + if not OURS.search(git):
212 + continue
213 + name = spec.get("package") if isinstance(spec.get("package"), str) else key
214 + known = versions.get(name)
215 + if known is None:
216 + # A repo that is not on this machine (ripgrow lives on mbp
217 + # only). Not a finding: nothing here can be wrong about it.
218 + absent.add(name)
219 + continue
220 + verdict = satisfies(req, known[0])
221 + if verdict is None:
222 + unchecked += 1
223 + continue
224 + graded += 1
225 + if not verdict:
226 + broken.append((p, name, req, known[0], known[1]))
227 +
228 + if not broken:
229 + print(
230 + f"pre-push: internal deps coherent ({graded} requirements"
231 + + (f", {unchecked} unchecked" if unchecked else "")
232 + + (f", {len(absent)} crates not in this tree" if absent else "")
233 + + ")."
234 + )
235 + return 0
236 +
237 + def rel(path):
238 + return os.path.relpath(path, tree)
239 +
240 + ours, theirs = [], []
241 + for item in broken:
242 + consumer_manifest, name, req, have, provider_manifest = item
243 + mine = repo is not None and (
244 + consumer_manifest.startswith(repo + os.sep)
245 + or provider_manifest.startswith(repo + os.sep)
246 + )
247 + (ours if mine else theirs).append(item)
248 +
249 + for consumer_manifest, name, req, have, provider_manifest in ours + theirs:
250 + print(
251 + f" {rel(consumer_manifest)}: requires {name} \"{req}\", "
252 + f"the tree has {have} ({rel(provider_manifest)})",
253 + file=sys.stderr,
254 + )
255 +
256 + if repo is None:
257 + print(f"internal deps: {len(broken)} unresolvable requirements.", file=sys.stderr)
258 + return 1
259 +
260 + if not ours:
261 + # Somebody else's skew. Worth seeing, never worth blocking this push on:
262 + # a gate that fails for a reason the pusher cannot fix is a gate that
263 + # gets bypassed by reflex, and then it is not a gate.
264 + print(
265 + f"pre-push: {len(theirs)} unresolvable requirements elsewhere in the "
266 + "tree (listed above, not this push's).",
267 + )
268 + return 0
269 +
270 + print("", file=sys.stderr)
271 + print(
272 + "pre-push: this push leaves a dependency that cannot resolve.\n"
273 + " A version requirement states which major a consumer was written against,\n"
274 + " so bumping a library and fixing its consumers is one pass (CLAUDE.md,\n"
275 + " \"a breaking bump of an in-house crate is forward-fixed, in the same pass\").\n"
276 + " Fix: bump the requirement in the manifests above, make the consumers\n"
277 + " compile, and push them with this one.",
278 + file=sys.stderr,
279 + )
280 + return 1
281 +
282 +
283 + if __name__ == "__main__":
284 + sys.exit(main())
@@ -1,0 +1,212 @@
1 + #!/bin/bash
2 + # Canonical pre-commit gate. Byte-identical in every repo under ~/Code.
3 + #
4 + # DO NOT EDIT IN PLACE. The master is _private/infra/bootstrap/githooks/pre-commit
5 + # and install-githooks.sh --check reports any copy that has drifted from it. Edit
6 + # the master, re-run the installer, commit the repos it touched.
7 + #
8 + # Activate in a fresh clone (one-time):
9 + # git config core.hooksPath scripts/githooks
10 + # clone-tree.sh does this for every repo it clones, so only a hand clone needs it.
11 + #
12 + # Bypass for a work-in-progress commit: git commit --no-verify
13 + #
14 + # Every gate below decides for itself whether it applies, from what is in the repo
15 + # and what is staged. That is what lets one file serve a library, an app and a
16 + # server: the repo's shape selects the gates rather than a per-repo edit, which is
17 + # the drift that let makeover-immediate 0.18.0 reach its release preflight
18 + # unformatted and left eight violations sitting on quasi's main (infra a33fdaab).
19 + #
20 + # NOT here, deliberately: clippy. It is slow enough that a commit-time gate is one
21 + # people bypass, so it stays in CI and the sweep.
22 + #
23 + # Genuinely repo-local extras go in scripts/githooks/pre-commit.local, which this
24 + # runs last if it exists.
25 + set -euo pipefail
26 +
27 + ROOT="$(git rev-parse --show-toplevel)"
28 + cd "$ROOT"
29 +
30 + # git invoked from an editor, a cron job, or a non-interactive shell does not
31 + # source the profile that puts ~/.local/bin on PATH, and a hook that silently
32 + # cannot find gitleaks or cargo is worse than no hook. (Lesson from _private's
33 + # own hook, which is stricter still: it refuses to commit blind.)
34 + export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
35 +
36 + # --- secret scan (gitleaks) -------------------------------------------------
37 + # Independent guardrail: blocks a commit whose staged changes contain a secret,
38 + # regardless of whether a human judged the value "safe". Shared ruleset lives at
39 + # ~/Code/.gitleaks.toml. Degrades gracefully if gitleaks is not installed (the
40 + # astra pre-receive hook is the backstop that always runs). Task: infra 97ffeda0.
41 + if command -v gitleaks >/dev/null 2>&1; then
42 + GL_CFG=""
43 + if [ -f "$ROOT/.gitleaks.toml" ]; then
44 + GL_CFG="$ROOT/.gitleaks.toml"
45 + elif [ -f "$HOME/Code/.gitleaks.toml" ]; then
46 + GL_CFG="$HOME/Code/.gitleaks.toml"
47 + fi
48 + gl_args=(git --staged --no-banner --redact)
49 + [ -n "$GL_CFG" ] && gl_args+=(-c "$GL_CFG")
50 + if ! gitleaks "${gl_args[@]}"; then
51 + echo "pre-commit: gitleaks found a secret in the staged changes."
52 + echo " remove it (or allowlist a false positive), then restage."
53 + echo " bypass: git commit --no-verify."
54 + exit 1
55 + fi
56 + echo "pre-commit: gitleaks clean."
57 + else
58 + echo "pre-commit: gitleaks not installed; skipping secret scan (astra gates on push)."
59 + fi
60 +
61 + # --- migration immutability -------------------------------------------------
62 + # Applies to any repo with migrations, which is why it is not MNW-local: sqlx
63 + # checksums a migration's whole file when it runs it and refuses one whose bytes
64 + # changed since, so editing an already-applied migration breaks every deploy
65 + # against that database with "previously applied but modified" -- for a comment
66 + # edit, and for a line-ending change, exactly as much as for a schema change.
67 + #
68 + # The 2026-07-27 exorcise sweep rewrote comments in 29 applied MNW migrations and
69 + # converted one from CRLF to LF. Nothing in the test suite checksums a migration,
70 + # so it stayed invisible while it blocked every server deploy for three days.
71 + #
72 + # goingson and balanced_breakfast are in scope too: both kept their
73 + # `_sqlx_migrations` ledger verbatim through the 2026-08-07 rusqlite migration, so
74 + # an upgraded install still reads those rows and an edited file still contradicts
75 + # them. Adding a new migration is always fine; this only blocks M/D/R.
76 + touched="$(git diff --cached --name-only --diff-filter=MDR -- '*migrations/*.sql')"
77 + if [ -n "$touched" ]; then
78 + echo "pre-commit: these already-committed migrations were modified, renamed, or deleted:"
79 + while IFS= read -r m; do
80 + [ -n "$m" ] && echo " $m"
81 + done <<< "$touched"
82 + echo " A migration is immutable once applied; the runner checksums the"
83 + echo " whole file, comments included. Write a new migration instead."
84 + echo " Bypass ONLY if it has never been applied anywhere, including"
85 + echo " prod, staging, and your dev database: git commit --no-verify."
86 + exit 1
87 + fi
88 +
89 + # --- frontend design-system lint --------------------------------------------
90 + # Runs any scripts/lint-frontend.sh the repo carries when the commit touches a
91 + # frontend asset. Each of those scripts resolves its own paths from its location,
92 + # so finding them is enough and no path knowledge belongs here. Both known scripts
93 + # live at repo root (goingson, balanced_breakfast) or one level down (MNW's is
94 + # server/scripts/lint-frontend.sh), hence the depth-2 search.
95 + #
96 + # This sits ABOVE the rustfmt gate deliberately: that gate exits early when no .rs
97 + # files are staged, which is exactly the case where a frontend commit needs
98 + # checking. goingson's copy also runs the JS suite, which carries the CHRONIC-XSS
99 + # escaping gate.
100 + staged_fe="$(git diff --cached --name-only --diff-filter=ACMR -- '*.js' '*.css' '*.html')"
101 + if [ -n "$staged_fe" ]; then
102 + while IFS= read -r lint; do
103 + [ -n "$lint" ] || continue
104 + if ! fe_out=$(bash "$lint" 2>&1); then
105 + echo "$fe_out"
106 + echo "pre-commit: frontend lint failed ($lint)."
107 + echo " fix the rules above, then restage."
108 + echo " bypass: git commit --no-verify."
109 + exit 1
110 + fi
111 + echo "pre-commit: frontend lint clean ($lint)."
112 + done <<< "$(find . -maxdepth 3 -path ./target -prune -o \
113 + -path '*/scripts/lint-frontend.sh' -print 2>/dev/null | sort)"
114 + fi
115 +
116 + # --- rustfmt ----------------------------------------------------------------
117 + # Blocks a commit whose staged Rust files are not formatted. Only crates with
118 + # staged .rs changes are checked, so the hook stays fast on a large repo. Each
119 + # file maps to the nearest enclosing Cargo.toml and the check runs as `cargo fmt`
120 + # there, which picks up that crate's edition and any rustfmt.toml rather than
121 + # guessing -- and is why this works unchanged in MNW, which has no root workspace.
122 + #
123 + # SKIP_PATHS is an extended regex of repo-relative paths to ignore. Empty means
124 + # check everything. Set it in pre-commit.local if a repo ever needs one.
125 + SKIP_PATHS="${SKIP_PATHS:-}"
126 +
127 + staged="$(git diff --cached --name-only --diff-filter=ACMR -- '*.rs')"
128 + if [ -n "$SKIP_PATHS" ]; then
129 + staged="$(printf '%s\n' "$staged" | grep -Ev "$SKIP_PATHS" || true)"
130 + fi
131 +
132 + if [ -n "$staged" ]; then
133 + # Map each staged file to the directory of its nearest Cargo.toml.
134 + crates=""
135 + while IFS= read -r f; do
136 + [ -n "$f" ] || continue
137 + d="$(dirname "$f")"
138 + while [ "$d" != "." ] && [ ! -f "$d/Cargo.toml" ]; do
139 + d="$(dirname "$d")"
140 + done
141 + [ -f "$d/Cargo.toml" ] || continue
142 + crates="$crates$d"$'\n'
143 + done <<< "$staged"
144 +
145 + crates="$(printf '%s' "$crates" | sort -u)"
146 +
147 + failed=0
148 + while IFS= read -r c; do
149 + [ -n "$c" ] || continue
150 + if ! (cd "$c" && cargo fmt --check >/dev/null 2>&1); then
151 + echo "pre-commit: rustfmt gate failed in $c"
152 + failed=1
153 + fi
154 + done <<< "$crates"
155 +
156 + if [ "$failed" -ne 0 ]; then
157 + echo "pre-commit: run 'cargo fmt' in the crates above, then restage."
158 + echo "pre-commit: commit aborted (use --no-verify to bypass)."
159 + exit 1
160 + fi
161 + echo "pre-commit: rustfmt gate clean."
162 + fi
163 +
164 + # --- openapi.json staleness -------------------------------------------------
165 + # Only fires in a repo that commits a generated spec, which today is MNW alone.
166 + #
167 + # server/openapi.json is a committed artifact and `openapi::tests::
168 + # committed_spec_matches_generated` asserts it matches the generated spec. The
169 + # spec embeds CARGO_PKG_VERSION, so EVERY version bump invalidates it even when no
170 + # route changed.
171 + #
172 + # Nothing local caught that. The /deploy pre-push guard is a `cargo test --no-run`
173 + # compile check, and the spec is read at runtime by path rather than include_str!,
174 + # so a stale copy compiles fine. On 2026-08-06 the v0.11.8 bump left the spec at
175 + # 0.11.7, pushed clean to all three remotes, and killed Sando run 38 about fifteen
176 + # minutes in -- two full remote build cycles for a one-line diff in info.version.
177 + #
178 + # So: regenerate to stdout and compare against the STAGED copy (not the working
179 + # tree one -- regenerating without restaging is the same bug wearing a hat).
180 + if [ -f "$ROOT/server/openapi.json" ]; then
181 + specish="$(git diff --cached --name-only --diff-filter=ACMR \
182 + -- 'server/Cargo.toml' 'server/src/*.rs' 'server/src/**/*.rs' 'server/openapi.json')"
183 + if [ -n "$specish" ]; then
184 + echo "pre-commit: checking openapi.json against the generated spec..."
185 + gen="$(mktemp)"
186 + trap 'rm -f "$gen"' EXIT
187 + if (cd "$ROOT/server" && cargo run --quiet --bin export-openapi -- --stdout) > "$gen" 2>/dev/null; then
188 + if ! git show :server/openapi.json 2>/dev/null | diff -q - "$gen" >/dev/null; then
189 + echo "pre-commit: server/openapi.json is stale (or regenerated but not staged)."
190 + echo " cd server && cargo run --bin export-openapi"
191 + echo " git add server/openapi.json"
192 + echo " Then vendor the same bytes into the OTHER repo, which this"
193 + echo " commit cannot carry and Sando will fail on:"
194 + echo " cp server/openapi.json ../synckit/synckit-client/tests/openapi.json"
195 + echo " Bypass: git commit --no-verify."
196 + exit 1
197 + fi
198 + echo "pre-commit: openapi.json current."
199 + else
200 + echo "pre-commit: could not build export-openapi; skipping spec check."
201 + echo " cargo_test in Sando is the backstop, 15 minutes into the build."
202 + fi
203 + fi
204 + fi
205 +
206 + # --- repo-local extras ------------------------------------------------------
207 + # The escape hatch for a gate that cannot be selected from the repo's shape. Keep
208 + # it small: anything a second repo wants belongs in the canonical file above,
209 + # guarded by its own detection.
210 + if [ -f "$ROOT/scripts/githooks/pre-commit.local" ]; then
211 + bash "$ROOT/scripts/githooks/pre-commit.local" || exit 1
212 + fi
@@ -1,0 +1,84 @@
1 + #!/bin/bash
2 + # Canonical pre-push gate. Two gates, and they answer different questions:
3 + #
4 + # internal deps does every in-house `version` requirement in the tree still
5 + # resolve against the crate it names? Runs in EVERY repo.
6 + # test targets do this repo's test targets build? Runs where there is a
7 + # root Cargo.toml to run one command in.
8 + #
9 + # DO NOT EDIT IN PLACE. The master is _private/infra/bootstrap/githooks/pre-push.
10 + #
11 + # Bypass for a work-in-progress push: git push --no-verify
12 + #
13 + # WHY THE FIRST GATE EXISTS. Every cross-repo dependency carries a `version`
14 + # alongside its `git` URL, so cargo refuses a sibling it was not written against
15 + # instead of compiling something surprising. That requirement is the protection
16 + # and it is also the maintenance: bumping a library's minor breaks every consumer
17 + # whose requirement excludes it, and CLAUDE.md's rule is that the bump and the
18 + # forward fix are one pass. Nothing enforced the rule, so quasi went 0.11 -> 0.14
19 + # over two evenings and MNW's server could not resolve at all for a day. The
20 + # nightly sweep found it and a red cell in a grid is not the same as being told.
21 + #
22 + # This gate is that rule, mechanised, at the moment it is broken: the push that
23 + # would leave a consumer unable to build is the push that is refused. It reads
24 + # the WORKING COPIES in the tree, not the remotes, because `~/Code/.cargo/config.toml`
25 + # redirects every one of these dependencies to the working copy -- so a local bump
26 + # breaks a consumer's build here whether or not it has been pushed anywhere.
27 + #
28 + # `cargo check` and `cargo clippy` both compile only the lib and bin targets, so a
29 + # break confined to `tests/` or a `#[cfg(test)]` module is clean under both and
30 + # lands unnoticed (goingson's sqlx 0.9 upgrade shipped exactly that way).
31 + # `--no-run` builds every test target without running them, which is the cheap
32 + # half of the suite and enough to catch a compile break. Tests still run
33 + # separately.
34 + #
35 + # `--workspace` is load-bearing wherever default-members is narrower than the
36 + # workspace: goingson's is src-tauri alone, so a bare `cargo test --no-run` would
37 + # skip core, db-sqlite, go-mcp and got.
38 + set -euo pipefail
39 +
40 + ROOT="$(git rev-parse --show-toplevel)"
41 + cd "$ROOT"
42 +
43 + # See the canonical pre-commit: a hook run from an editor or a cron job does not
44 + # get the profile's PATH, and a hook that cannot find cargo is worse than none.
45 + export PATH="$HOME/.cargo/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
46 +
47 + # Refs arrive on stdin as "<local ref> <local sha> <remote ref> <remote sha>".
48 + # A branch deletion has an all-zero local sha and no tree to push. Read once,
49 + # ahead of both gates: stdin is not seekable and a second reader gets nothing.
50 + pushing=0
51 + while read -r _local_ref local_sha _remote_ref _remote_sha; do
52 + case "$local_sha" in
53 + *[!0]*) pushing=1 ;;
54 + esac
55 + done
56 + [ "$pushing" -eq 1 ] || exit 0
57 +
58 + # ── gate 1: internal dependency coherence ──────────────────────────────────
59 + #
60 + # Deliberately no cargo: this reads manifests and answers in well under a second,
61 + # where `cargo metadata` on the server is tens of seconds and fails outright on
62 + # exactly the state being detected.
63 + CODE_ROOT="${CODE_ROOT:-$HOME/Code}"
64 + if [ -d "$CODE_ROOT" ] && command -v python3 >/dev/null 2>&1; then
65 + if ! python3 "$ROOT/scripts/githooks/internal-deps.py" "$CODE_ROOT" "$ROOT"; then
66 + echo "pre-push: push aborted (use --no-verify to bypass)."
67 + exit 1
68 + fi
69 + fi
70 +
71 + # ── gate 2: test targets build ─────────────────────────────────────────────
72 + #
73 + # MNW and synckit have no root Cargo.toml by design (standalone crates, no root
74 + # workspace), so there is no one command to run and they get gate 1 only.
75 + [ -f "$ROOT/Cargo.toml" ] || exit 0
76 +
77 + echo "pre-push: building test targets (cargo test --no-run --workspace)..."
78 + if ! cargo test --no-run --workspace; then
79 + echo "pre-push: test targets failed to build."
80 + echo "pre-push: push aborted (use --no-verify to bypass)."
81 + exit 1
82 + fi
83 +
84 + echo "pre-push: test targets build clean."
A src/lib.rs +441
@@ -1,0 +1,441 @@
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 + }